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//! # Example
30//!
31//! ```no_run
32//! use dataprof_runtime::{ProfileReport, QualityPolicy, Verdict};
33//!
34//! fn gate(report: &ProfileReport) -> Result<bool, Box<dyn std::error::Error>> {
35//!     let result = QualityPolicy::new()
36//!         .min_quality_score(90.0)
37//!         .max_null_percentage("customer_id", 0.0)
38//!         .max_null_percentage_any(20.0)
39//!         .require_quality()
40//!         .evaluate(report)?;
41//!
42//!     if result.verdict != Verdict::Pass {
43//!         for violation in result.violations() {
44//!             eprintln!("{}: {}", violation.code, violation.message);
45//!         }
46//!     }
47//!     Ok(result.passed())
48//! }
49//! ```
50
51use std::collections::BTreeMap;
52use std::fmt;
53
54use dataprof_core::{ColumnProfile, ExecutionMetadata, QualityDimension};
55use dataprof_metrics::{QualityAssessment, ScoreBounds, ScoreInterval};
56
57use crate::profile_report::{ProfileReport, QualityAnalysisStatus};
58
59/// Scores and percentages in a report live on a 0..=100 scale, and so do the
60/// thresholds compared against them.
61const PERCENTAGE_MAX: f64 = 100.0;
62
63/// A policy that cannot be evaluated as written.
64///
65/// Raised before any requirement is checked: an unsatisfiable threshold is a
66/// configuration mistake, and reporting it as a failed gate would blame the
67/// data for it.
68#[derive(Debug, Clone, PartialEq)]
69pub enum PolicyError {
70    /// A threshold on the 0..=100 percentage scale fell outside it, or was not
71    /// a finite number. Quality scores and null percentages are percentages,
72    /// not 0..1 ratios.
73    ThresholdOutOfRange {
74        /// The requirement that carried it.
75        code: CheckCode,
76        /// The column or dimension the requirement names, when it names one.
77        subject: Option<String>,
78        /// The value as supplied.
79        value: f64,
80    },
81    /// The policy states no requirement at all. An empty policy passes every
82    /// report, which is never what a gate is for.
83    NoRequirements,
84}
85
86impl fmt::Display for PolicyError {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            Self::ThresholdOutOfRange {
90                code,
91                subject,
92                value,
93            } => {
94                write!(f, "{code} threshold ")?;
95                if let Some(subject) = subject {
96                    write!(f, "for `{subject}` ")?;
97                }
98                write!(
99                    f,
100                    "must be a percentage between 0 and {PERCENTAGE_MAX}, got {value}"
101                )
102            }
103            Self::NoRequirements => write!(
104                f,
105                "policy states no requirement; a gate must check something"
106            ),
107        }
108    }
109}
110
111impl std::error::Error for PolicyError {}
112
113/// What the requirements in a policy are statements about.
114///
115/// A profiler reads what it is given. Whether that supports a claim about the
116/// whole source depends on what is being claimed, so the policy says which
117/// question it asks rather than letting the evaluator guess.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum PolicyScope {
121    /// The entire source. A requirement is evaluated only when the evidence
122    /// covers it, or when the observed data already witnesses a violation that
123    /// further scanning cannot retract.
124    #[default]
125    FullSource,
126    /// Whatever the metric actually measured — the scanned rows, the retained
127    /// quality sample, the profiled columns. Always evaluable, and says
128    /// nothing about the rows that were not read.
129    Observed,
130}
131
132impl fmt::Display for PolicyScope {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self {
135            Self::FullSource => write!(f, "full_source"),
136            Self::Observed => write!(f, "observed"),
137        }
138    }
139}
140
141impl std::str::FromStr for PolicyScope {
142    type Err = String;
143
144    fn from_str(s: &str) -> Result<Self, Self::Err> {
145        match s {
146            "full_source" => Ok(Self::FullSource),
147            "observed" => Ok(Self::Observed),
148            _ => Err(format!(
149                "Unknown policy scope: {s}. Valid scopes: full_source, observed"
150            )),
151        }
152    }
153}
154
155/// Why the data behind a number falls short of the whole source.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
157#[serde(rename_all = "snake_case")]
158pub enum EvidenceGap {
159    /// Profiling stopped before the source was exhausted.
160    Truncated,
161    /// Rows were sampled rather than all read.
162    Sampled,
163    /// Records were skipped because they could not be turned into rows.
164    RecordsSkipped,
165    /// Every row was scanned, but this metric was computed from a retained
166    /// sample of them.
167    QualitySampled,
168    /// The report does not record how its quality numbers were obtained, so
169    /// whether they cover every scanned row is unknown. Only reachable for a
170    /// report read back from a document written before dataprof recorded it;
171    /// a profiling run always records it. Unknown coverage is not full
172    /// coverage, so a full-source requirement is left unevaluated.
173    CoverageUnrecorded,
174}
175
176impl fmt::Display for EvidenceGap {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        match self {
179            Self::Truncated => write!(f, "truncated"),
180            Self::Sampled => write!(f, "sampled"),
181            Self::RecordsSkipped => write!(f, "records_skipped"),
182            Self::QualitySampled => write!(f, "quality_sampled"),
183            Self::CoverageUnrecorded => write!(f, "coverage_unrecorded"),
184        }
185    }
186}
187
188/// Whether the data behind a number covers the whole source.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
190#[serde(tag = "coverage", rename_all = "snake_case")]
191pub enum Evidence {
192    /// Every row of the source is behind the number.
193    Complete,
194    /// Some of the source is not, for this reason.
195    Incomplete {
196        /// The gap between what was read and the whole source.
197        reason: EvidenceGap,
198    },
199}
200
201impl Evidence {
202    /// True when the whole source is behind the number.
203    pub fn is_complete(self) -> bool {
204        matches!(self, Self::Complete)
205    }
206
207    /// The gap, when there is one.
208    pub fn gap(self) -> Option<EvidenceGap> {
209        match self {
210            Self::Complete => None,
211            Self::Incomplete { reason } => Some(reason),
212        }
213    }
214
215    /// The weaker of two evidence statements: a number is only as complete as
216    /// the least complete input behind it. The left-hand gap wins a tie, so
217    /// the scan's gap is named ahead of the quality sample's.
218    fn and(self, other: Self) -> Self {
219        match self {
220            Self::Complete => other,
221            incomplete => incomplete,
222        }
223    }
224}
225
226/// Stable identifier for the requirement a check came from.
227///
228/// Part of the serialized contract: a CI system matches on these, so they do
229/// not change when the prose does.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
231#[serde(rename_all = "snake_case")]
232pub enum CheckCode {
233    /// The overall quality score must be at least the threshold.
234    MinQualityScore,
235    /// One ISO 25012 dimension score must be at least the threshold.
236    MinDimensionScore,
237    /// A column's null percentage must be at most the threshold.
238    MaxNullPercentage,
239    /// The duplicate-row count must be at most the threshold.
240    MaxDuplicateRows,
241    /// A metric must have been analyzed at all.
242    RequireMetric,
243}
244
245impl fmt::Display for CheckCode {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        match self {
248            Self::MinQualityScore => write!(f, "min_quality_score"),
249            Self::MinDimensionScore => write!(f, "min_dimension_score"),
250            Self::MaxNullPercentage => write!(f, "max_null_percentage"),
251            Self::MaxDuplicateRows => write!(f, "max_duplicate_rows"),
252            Self::RequireMetric => write!(f, "require_metric"),
253        }
254    }
255}
256
257/// A number a check read, or a threshold it compared against.
258///
259/// Percentages follow the report's 2dp serialization convention; counts are
260/// whole numbers and are not widened into rounded floats.
261#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
262#[serde(untagged)]
263pub enum MetricValue {
264    /// A count of rows or values.
265    Count(usize),
266    /// A 0..=100 percentage or score.
267    Percentage(#[serde(serialize_with = "dataprof_core::serde_helpers::round_2")] f64),
268}
269
270impl fmt::Display for MetricValue {
271    /// For logs and messages, not for the serialized contract: percentages
272    /// print at the report's 2dp convention, counts as whole numbers.
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        match self {
275            Self::Count(count) => write!(f, "{count}"),
276            Self::Percentage(value) => write!(f, "{value:.2}"),
277        }
278    }
279}
280
281/// The constraint a check applied.
282#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
283#[serde(tag = "comparison", rename_all = "snake_case")]
284pub enum Expectation {
285    /// The observed value must be at least `value`.
286    AtLeast {
287        /// The threshold.
288        value: MetricValue,
289    },
290    /// The observed value must be at most `value`.
291    AtMost {
292        /// The threshold.
293        value: MetricValue,
294    },
295    /// The metric must have been analyzed. No number is compared.
296    Analyzed,
297}
298
299impl fmt::Display for Expectation {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        match self {
302            Self::AtLeast { value } => write!(f, "at least {value}"),
303            Self::AtMost { value } => write!(f, "at most {value}"),
304            Self::Analyzed => write!(f, "analyzed"),
305        }
306    }
307}
308
309/// Why a requirement was not evaluated.
310///
311/// Absence is never read as zero and never as a pass. Each variant names a
312/// different thing a caller may want to fix: a run that did not ask for the
313/// metric, a dimension with nothing to measure, a column that was not
314/// profiled, or a scan that does not reach as far as the requirement does.
315#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
316#[serde(tag = "reason", rename_all = "snake_case")]
317pub enum NotEvaluated {
318    /// The report carries no quality assessment, for this recorded reason.
319    QualityUnavailable {
320        /// The report's `quality_status` state, verbatim.
321        quality_status: String,
322    },
323    /// Quality was computed, but this dimension had nothing to assess — no
324    /// numeric values, no dates, no confidently detected pattern — or it was
325    /// withheld because the run profiled a subset of columns.
326    NotAssessed,
327    /// The report holds no profile for the named column. It was projected
328    /// away, or it is not in the source; a report does not record which, so
329    /// the requirement is not decided either way.
330    ColumnNotProfiled,
331    /// The requirement asks about the full source, the evidence does not reach
332    /// that far, and nothing observed settles it regardless.
333    EvidenceIncomplete {
334        /// The gap between the evidence and the requirement's scope.
335        gap: EvidenceGap,
336    },
337}
338
339/// What happened to one requirement.
340#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
341#[serde(tag = "status", rename_all = "snake_case")]
342pub enum CheckStatus {
343    /// Evaluated and met.
344    Passed,
345    /// Evaluated and violated.
346    Failed,
347    /// Not evaluated, for this reason.
348    NotEvaluated(NotEvaluated),
349}
350
351/// One requirement and what the report said about it.
352#[derive(Debug, Clone, PartialEq, serde::Serialize)]
353pub struct Check {
354    /// Which requirement this is.
355    pub code: CheckCode,
356    /// The column it concerns, when it concerns one.
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub column: Option<String>,
359    /// The quality dimension it concerns, when it concerns one.
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub dimension: Option<QualityDimension>,
362    /// The constraint applied.
363    pub expected: Expectation,
364    /// The value read from the report. Absent when nothing was read: every
365    /// unevaluated check, and every `require_metric` check.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub observed: Option<MetricValue>,
368    /// What the requirement is a statement about.
369    pub scope: PolicyScope,
370    /// Whether the data behind `observed` covers the whole source.
371    pub evidence: Evidence,
372    /// Where the whole-source value lies, when `observed` came from a retained
373    /// sample and the report bounds it. A full-source requirement is decided
374    /// on this interval rather than on `observed`: it passes when the whole
375    /// interval meets the requirement, fails when none of it does, and is
376    /// otherwise not evaluated.
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub bounds: Option<CheckBounds>,
379    /// Passed, failed, or not evaluated.
380    #[serde(flatten)]
381    pub status: CheckStatus,
382    /// A fixed sentence naming the outcome.
383    ///
384    /// Deliberately free of numbers: `observed` and `expected` carry those, so
385    /// the prose is identical wherever a value would have been formatted and
386    /// the Rust and Python implementations cannot drift on rounding inside a
387    /// string.
388    pub message: String,
389}
390
391impl Check {
392    /// True when the check was evaluated and violated.
393    pub fn is_violation(&self) -> bool {
394        matches!(self.status, CheckStatus::Failed)
395    }
396
397    /// Why the check was not evaluated, if it was not.
398    pub fn not_evaluated(&self) -> Option<&NotEvaluated> {
399        match &self.status {
400            CheckStatus::NotEvaluated(reason) => Some(reason),
401            _ => None,
402        }
403    }
404}
405
406/// The interval a sampled score was decided on.
407#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
408pub struct CheckBounds {
409    /// Lowest value the whole-source score can take.
410    #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
411    pub lower: f64,
412    /// Highest value the whole-source score can take.
413    #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
414    pub upper: f64,
415    /// Probability that every interval in the report holds at once.
416    pub confidence_level: f64,
417}
418
419/// The overall outcome of a policy.
420#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
421#[serde(rename_all = "snake_case")]
422pub enum Verdict {
423    /// Every requirement was evaluated and met.
424    Pass,
425    /// At least one requirement was conclusively violated.
426    Fail,
427    /// Nothing was violated, and at least one requirement could not be
428    /// evaluated. Not a pass: the gate has no answer, not a positive one.
429    Inconclusive,
430}
431
432impl fmt::Display for Verdict {
433    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
434        match self {
435            Self::Pass => write!(f, "pass"),
436            Self::Fail => write!(f, "fail"),
437            Self::Inconclusive => write!(f, "inconclusive"),
438        }
439    }
440}
441
442/// The structured result of evaluating a [`QualityPolicy`].
443#[derive(Debug, Clone, PartialEq, serde::Serialize)]
444pub struct GateResult {
445    /// The overall outcome.
446    pub verdict: Verdict,
447    /// The scope every requirement was evaluated under.
448    pub scope: PolicyScope,
449    /// Whether the scan itself covered the whole source. A single check's
450    /// evidence can be weaker still, when its metric came from a retained
451    /// sample of the scanned rows.
452    pub evidence: Evidence,
453    /// Every requirement, in evaluation order.
454    pub checks: Vec<Check>,
455}
456
457impl GateResult {
458    /// True only for [`Verdict::Pass`].
459    ///
460    /// An inconclusive result is not a pass: reading it as one is the mistake
461    /// this API exists to prevent. Callers that need to tell "violated" from
462    /// "could not tell" should match on [`GateResult::verdict`].
463    pub fn passed(&self) -> bool {
464        self.verdict == Verdict::Pass
465    }
466
467    /// The checks that were evaluated and violated.
468    pub fn violations(&self) -> impl Iterator<Item = &Check> {
469        self.checks.iter().filter(|check| check.is_violation())
470    }
471
472    /// The checks that could not be evaluated.
473    pub fn unevaluated(&self) -> impl Iterator<Item = &Check> {
474        self.checks
475            .iter()
476            .filter(|check| check.not_evaluated().is_some())
477    }
478}
479
480/// A metric a policy requires to have been analyzed.
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum RequiredMetric {
483    /// The quality assessment as a whole.
484    Quality,
485    /// One ISO 25012 dimension.
486    Dimension(QualityDimension),
487}
488
489/// What a report must satisfy.
490///
491/// Build one with the chainable setters, then [`evaluate`](Self::evaluate) it
492/// against a report. Requirements are evaluated in a fixed order that does not
493/// depend on the order they were added, so two callers stating the same policy
494/// get the same document out.
495#[derive(Debug, Clone, Default)]
496pub struct QualityPolicy {
497    min_quality_score: Option<f64>,
498    min_dimension_scores: BTreeMap<String, (QualityDimension, f64)>,
499    max_null_percentage: BTreeMap<String, f64>,
500    max_null_percentage_any: Option<f64>,
501    max_duplicate_rows: Option<usize>,
502    require_metrics: Vec<RequiredMetric>,
503    scope: PolicyScope,
504}
505
506impl QualityPolicy {
507    /// An empty policy. It states no requirement, so evaluating it as-is is a
508    /// [`PolicyError::NoRequirements`].
509    pub fn new() -> Self {
510        Self::default()
511    }
512
513    /// Require the overall quality score to be at least `min` (0..=100).
514    pub fn min_quality_score(mut self, min: f64) -> Self {
515        self.min_quality_score = Some(min);
516        self
517    }
518
519    /// Require one dimension's score to be at least `min` (0..=100).
520    pub fn min_dimension_score(mut self, dimension: QualityDimension, min: f64) -> Self {
521        self.min_dimension_scores
522            .insert(dimension.to_string(), (dimension, min));
523        self
524    }
525
526    /// Require the named column's null percentage to be at most `max`
527    /// (0..=100). Takes precedence over
528    /// [`max_null_percentage_any`](Self::max_null_percentage_any) for that
529    /// column.
530    pub fn max_null_percentage(mut self, column: impl Into<String>, max: f64) -> Self {
531        self.max_null_percentage.insert(column.into(), max);
532        self
533    }
534
535    /// Require every other profiled column's null percentage to be at most
536    /// `max` (0..=100).
537    pub fn max_null_percentage_any(mut self, max: f64) -> Self {
538        self.max_null_percentage_any = Some(max);
539        self
540    }
541
542    /// Require the report to count at most `max` duplicate rows.
543    pub fn max_duplicate_rows(mut self, max: usize) -> Self {
544        self.max_duplicate_rows = Some(max);
545        self
546    }
547
548    /// Require the report to carry a quality assessment at all.
549    ///
550    /// Without this, a report with no assessment leaves quality requirements
551    /// unevaluated and the verdict inconclusive. With it, the absence is
552    /// itself a violation — which is what a pipeline wants when quality is
553    /// supposed to be configured on.
554    pub fn require_quality(mut self) -> Self {
555        self.require_metrics.push(RequiredMetric::Quality);
556        self
557    }
558
559    /// Require a specific dimension to have been assessed.
560    pub fn require_dimension(mut self, dimension: QualityDimension) -> Self {
561        self.require_metrics
562            .push(RequiredMetric::Dimension(dimension));
563        self
564    }
565
566    /// Set what the requirements are statements about. Defaults to
567    /// [`PolicyScope::FullSource`].
568    pub fn scope(mut self, scope: PolicyScope) -> Self {
569        self.scope = scope;
570        self
571    }
572
573    /// Check that the policy is evaluable, without needing a report.
574    ///
575    /// Callers that read a policy out of configuration should do this at
576    /// startup rather than discovering a bad threshold on the first dataset.
577    pub fn validate(&self) -> Result<(), PolicyError> {
578        if let Some(min) = self.min_quality_score {
579            check_percentage(CheckCode::MinQualityScore, None, min)?;
580        }
581        for (dimension, min) in self.min_dimension_scores.values() {
582            check_percentage(
583                CheckCode::MinDimensionScore,
584                Some(dimension.to_string()),
585                *min,
586            )?;
587        }
588        for (column, max) in &self.max_null_percentage {
589            check_percentage(CheckCode::MaxNullPercentage, Some(column.clone()), *max)?;
590        }
591        if let Some(max) = self.max_null_percentage_any {
592            check_percentage(CheckCode::MaxNullPercentage, None, max)?;
593        }
594        if self.is_empty() {
595            return Err(PolicyError::NoRequirements);
596        }
597        Ok(())
598    }
599
600    fn is_empty(&self) -> bool {
601        self.min_quality_score.is_none()
602            && self.min_dimension_scores.is_empty()
603            && self.max_null_percentage.is_empty()
604            && self.max_null_percentage_any.is_none()
605            && self.max_duplicate_rows.is_none()
606            && self.require_metrics.is_empty()
607    }
608
609    /// Evaluate the policy against a report.
610    ///
611    /// Returns the structured outcome. The report is not modified, nothing is
612    /// printed, and the process is not exited: what to do about a failing gate
613    /// is the caller's decision.
614    pub fn evaluate(&self, report: &ProfileReport) -> Result<GateResult, PolicyError> {
615        self.validate()?;
616
617        let scan = scan_evidence(&report.execution);
618        let mut checks = Vec::new();
619
620        for metric in required_metrics_in_order(&self.require_metrics) {
621            checks.push(self.require_metric_check(report, metric));
622        }
623        if let Some(min) = self.min_quality_score {
624            checks.push(self.quality_score_check(report, scan, min));
625        }
626        for dimension in QualityDimension::all() {
627            if let Some((_, min)) = self.min_dimension_scores.get(&dimension.to_string()) {
628                checks.push(self.dimension_score_check(report, scan, dimension, *min));
629            }
630        }
631        checks.extend(self.null_percentage_checks(report, scan));
632        if let Some(max) = self.max_duplicate_rows {
633            checks.push(self.duplicate_rows_check(report, scan, max));
634        }
635
636        Ok(GateResult {
637            verdict: verdict_of(&checks),
638            scope: self.scope,
639            evidence: scan,
640            checks,
641        })
642    }
643
644    /// Decide a comparison whose observed value is an average or a ratio.
645    ///
646    /// Such a value, computed over part of a source, bounds nothing about the
647    /// rest of it, so under [`PolicyScope::FullSource`] incomplete evidence
648    /// leaves the requirement unevaluated in both directions. Under
649    /// [`PolicyScope::Observed`] the question is about the data that was
650    /// measured, so the comparison always stands.
651    fn decide_aggregate(&self, evidence: Evidence, satisfied: bool) -> CheckStatus {
652        match (self.scope, evidence.gap()) {
653            (PolicyScope::FullSource, Some(gap)) => {
654                CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
655            }
656            _ if satisfied => CheckStatus::Passed,
657            _ => CheckStatus::Failed,
658        }
659    }
660
661    fn require_metric_check(&self, report: &ProfileReport, metric: RequiredMetric) -> Check {
662        let dimension = match metric {
663            RequiredMetric::Quality => None,
664            RequiredMetric::Dimension(dimension) => Some(dimension),
665        };
666        // Availability is a property of the report, not of how much of the
667        // source it covers: a sampled run still either analyzed the metric or
668        // did not. So this check never consults evidence.
669        let analyzed = match (report.quality.as_ref(), dimension) {
670            (None, _) => false,
671            (Some(_), None) => true,
672            (Some(quality), Some(dimension)) => {
673                quality.metrics.dimension_score(dimension).is_some()
674            }
675        };
676        let message = match (analyzed, dimension) {
677            (true, None) => "quality was analyzed",
678            (false, None) => "the report carries no quality assessment",
679            (true, Some(_)) => "the required dimension was assessed",
680            (false, Some(_)) => "the required dimension was not assessed",
681        };
682        Check {
683            code: CheckCode::RequireMetric,
684            column: None,
685            dimension,
686            expected: Expectation::Analyzed,
687            observed: None,
688            scope: self.scope,
689            evidence: Evidence::Complete,
690            bounds: None,
691            status: if analyzed {
692                CheckStatus::Passed
693            } else {
694                CheckStatus::Failed
695            },
696            message: message.to_string(),
697        }
698    }
699
700    fn quality_score_check(&self, report: &ProfileReport, scan: Evidence, min: f64) -> Check {
701        let mut check = Check {
702            code: CheckCode::MinQualityScore,
703            column: None,
704            dimension: None,
705            expected: Expectation::AtLeast {
706                value: MetricValue::Percentage(min),
707            },
708            observed: None,
709            scope: self.scope,
710            evidence: scan.and(quality_evidence(
711                report.quality.as_ref(),
712                Provenance::Overall,
713            )),
714            bounds: None,
715            status: CheckStatus::Passed,
716            message: String::new(),
717        };
718        let Some(quality) = report.quality.as_ref() else {
719            return unavailable_quality(check, report);
720        };
721        let Some(score) = quality.score() else {
722            check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
723            check.message =
724                "no quality dimension had anything to assess, so there is no overall score"
725                    .to_string();
726            return check;
727        };
728        check.observed = Some(MetricValue::Percentage(score));
729        let bounds = quality
730            .score_bounds()
731            .and_then(|bounds| check_bounds(bounds, bounds.overall_score));
732        self.decide_score(&mut check, score, min, bounds, "the overall quality score");
733        check
734    }
735
736    fn dimension_score_check(
737        &self,
738        report: &ProfileReport,
739        scan: Evidence,
740        dimension: QualityDimension,
741        min: f64,
742    ) -> Check {
743        let mut check = Check {
744            code: CheckCode::MinDimensionScore,
745            column: None,
746            dimension: Some(dimension),
747            expected: Expectation::AtLeast {
748                value: MetricValue::Percentage(min),
749            },
750            observed: None,
751            scope: self.scope,
752            evidence: scan.and(quality_evidence(
753                report.quality.as_ref(),
754                Provenance::Dimension(dimension),
755            )),
756            bounds: None,
757            status: CheckStatus::Passed,
758            message: String::new(),
759        };
760        let Some(quality) = report.quality.as_ref() else {
761            return unavailable_quality(check, report);
762        };
763        let Some(score) = quality
764            .scores()
765            .dimension_scores
766            .get(&dimension.to_string())
767            .copied()
768            .flatten()
769        else {
770            check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
771            check.message = "this dimension had nothing to assess in this run".to_string();
772            return check;
773        };
774        check.observed = Some(MetricValue::Percentage(score));
775        let bounds = quality.score_bounds().and_then(|bounds| {
776            check_bounds(
777                bounds,
778                bounds
779                    .dimension_scores
780                    .get(&dimension.to_string())
781                    .copied()
782                    .flatten(),
783            )
784        });
785        self.decide_score(&mut check, score, min, bounds, "this dimension's score");
786        check
787    }
788
789    /// Decide a minimum on a score, on its whole-source interval when the
790    /// only gap in the evidence is the quality sample and the report bounds
791    /// the score. The interval settles the requirement when it lies wholly on
792    /// one side of the minimum; a minimum inside it is left unevaluated.
793    fn decide_score(
794        &self,
795        check: &mut Check,
796        score: f64,
797        min: f64,
798        bounds: Option<CheckBounds>,
799        subject: &str,
800    ) {
801        let sampled = check.evidence.gap() == Some(EvidenceGap::QualitySampled);
802        match bounds {
803            Some(bounds) if self.scope == PolicyScope::FullSource && sampled => {
804                check.bounds = Some(bounds);
805                check.status = if bounds.lower >= min {
806                    CheckStatus::Passed
807                } else if bounds.upper < min {
808                    CheckStatus::Failed
809                } else {
810                    CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete {
811                        gap: EvidenceGap::QualitySampled,
812                    })
813                };
814                check.message = bounded_message(&check.status, subject);
815            }
816            _ => {
817                check.status = self.decide_aggregate(check.evidence, score >= min);
818                check.message = aggregate_message(&check.status, subject);
819            }
820        }
821    }
822
823    /// One check per named column, in column-name order, then one per
824    /// remaining profiled column when a wildcard limit is set, in report
825    /// column order.
826    ///
827    /// The order is fixed by the data rather than by how the policy was
828    /// written, so a policy read out of a JSON object — where key order is not
829    /// preserved uniformly across languages — still evaluates identically.
830    fn null_percentage_checks(&self, report: &ProfileReport, scan: Evidence) -> Vec<Check> {
831        let mut checks = Vec::new();
832        for (column, max) in &self.max_null_percentage {
833            let profile = report
834                .column_profiles
835                .iter()
836                .find(|candidate| &candidate.name == column);
837            checks.push(self.null_percentage_check(column, profile, scan, *max));
838        }
839        if let Some(max) = self.max_null_percentage_any {
840            for profile in &report.column_profiles {
841                if !self.max_null_percentage.contains_key(&profile.name) {
842                    checks.push(self.null_percentage_check(
843                        &profile.name,
844                        Some(profile),
845                        scan,
846                        max,
847                    ));
848                }
849            }
850        }
851        checks
852    }
853
854    fn null_percentage_check(
855        &self,
856        column: &str,
857        profile: Option<&ColumnProfile>,
858        scan: Evidence,
859        max: f64,
860    ) -> Check {
861        // Null counts accumulate over every scanned row rather than over the
862        // retained quality sample, so only the scan's own gap applies here.
863        let mut check = Check {
864            code: CheckCode::MaxNullPercentage,
865            column: Some(column.to_string()),
866            dimension: None,
867            expected: Expectation::AtMost {
868                value: MetricValue::Percentage(max),
869            },
870            observed: None,
871            scope: self.scope,
872            evidence: scan,
873            bounds: None,
874            status: CheckStatus::Passed,
875            message: String::new(),
876        };
877        let Some(profile) = profile else {
878            check.status = CheckStatus::NotEvaluated(NotEvaluated::ColumnNotProfiled);
879            check.message = "this column has no profile in the report".to_string();
880            return check;
881        };
882        let Some(percentage) = null_percentage(profile) else {
883            // No value was read for the column, so "what share of its values
884            // are null" has no answer. Zero rows is not zero percent.
885            check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
886            check.message = "no values were read for this column".to_string();
887            return check;
888        };
889        check.observed = Some(MetricValue::Percentage(percentage));
890        check.status = self.decide_aggregate(check.evidence, percentage <= max);
891        check.message = match check.status {
892            CheckStatus::Passed => "this column's null percentage is within the allowance",
893            CheckStatus::Failed => "this column's null percentage is above the allowance",
894            CheckStatus::NotEvaluated(_) => {
895                "the scan does not cover the whole source, and a null percentage over \
896                 part of it bounds nothing about the rest"
897            }
898        }
899        .to_string();
900        check
901    }
902
903    /// A duplicate count is the one requirement here that an incomplete scan
904    /// can still settle in one direction: rows already witnessed as duplicates
905    /// do not stop being duplicates when more rows are read, so an exact count
906    /// above the allowance is a conclusive failure. A count at or below it is
907    /// not a pass — the rows that were not read may hold more. An estimated
908    /// count witnesses nothing and settles neither direction.
909    fn duplicate_rows_check(&self, report: &ProfileReport, scan: Evidence, max: usize) -> Check {
910        let mut check = Check {
911            code: CheckCode::MaxDuplicateRows,
912            column: None,
913            dimension: Some(QualityDimension::Uniqueness),
914            expected: Expectation::AtMost {
915                value: MetricValue::Count(max),
916            },
917            observed: None,
918            scope: self.scope,
919            evidence: scan.and(quality_evidence(
920                report.quality.as_ref(),
921                Provenance::Component("duplicate_rows"),
922            )),
923            bounds: None,
924            status: CheckStatus::Passed,
925            message: String::new(),
926        };
927        let Some(quality) = report.quality.as_ref() else {
928            return unavailable_quality(check, report);
929        };
930        let Some(uniqueness) = quality
931            .metrics
932            .uniqueness
933            .as_ref()
934            .filter(|metrics| metrics.rows_checked > 0)
935        else {
936            check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
937            check.message = "no rows were scanned for duplicates in this run".to_string();
938            return check;
939        };
940        let observed = uniqueness.duplicate_rows;
941        let exceeded = observed > max;
942        let estimated = uniqueness.duplicate_rows_approximate;
943        check.observed = Some(MetricValue::Count(observed));
944        check.status = match (self.scope, check.evidence.gap()) {
945            // A witnessed violation is a decision, whatever the scan missed.
946            (PolicyScope::FullSource, Some(_)) if exceeded && !estimated => CheckStatus::Failed,
947            (PolicyScope::FullSource, Some(gap)) => {
948                CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
949            }
950            _ if exceeded => CheckStatus::Failed,
951            _ => CheckStatus::Passed,
952        };
953        check.message = match check.status {
954            CheckStatus::Passed => "the duplicate-row count is within the allowance",
955            CheckStatus::Failed if estimated => {
956                "the estimated duplicate-row count is above the allowance"
957            }
958            CheckStatus::Failed => "duplicate rows were observed above the allowance",
959            // Every row may have been read: the gap is the estimate, which
960            // can neither witness duplicates nor rule them out (#819).
961            CheckStatus::NotEvaluated(_) if estimated => {
962                "the duplicate-row count is an estimate, which can neither witness \
963                 duplicates above the allowance nor rule them out"
964            }
965            CheckStatus::NotEvaluated(_) => {
966                "no duplicate above the allowance was observed, and the rows that were not \
967                 read may hold more"
968            }
969        }
970        .to_string();
971        check
972    }
973}
974
975fn check_percentage(
976    code: CheckCode,
977    subject: Option<String>,
978    value: f64,
979) -> Result<(), PolicyError> {
980    if value.is_finite() && (0.0..=PERCENTAGE_MAX).contains(&value) {
981        return Ok(());
982    }
983    Err(PolicyError::ThresholdOutOfRange {
984        code,
985        subject,
986        value,
987    })
988}
989
990/// Quality first, then dimensions in their declared order, each at most once.
991fn required_metrics_in_order(requested: &[RequiredMetric]) -> Vec<RequiredMetric> {
992    let mut ordered = Vec::new();
993    if requested.contains(&RequiredMetric::Quality) {
994        ordered.push(RequiredMetric::Quality);
995    }
996    for dimension in QualityDimension::all() {
997        if requested.contains(&RequiredMetric::Dimension(dimension)) {
998            ordered.push(RequiredMetric::Dimension(dimension));
999        }
1000    }
1001    ordered
1002}
1003
1004fn verdict_of(checks: &[Check]) -> Verdict {
1005    if checks.iter().any(Check::is_violation) {
1006        Verdict::Fail
1007    } else if checks.iter().any(|check| check.not_evaluated().is_some()) {
1008        Verdict::Inconclusive
1009    } else {
1010        Verdict::Pass
1011    }
1012}
1013
1014/// Fill in a check that had no quality assessment to read, naming the reason
1015/// the report recorded. Without that, "you did not ask for this" and "this
1016/// broke" reach a gate as the same absence.
1017fn unavailable_quality(mut check: Check, report: &ProfileReport) -> Check {
1018    let quality_status = quality_status_name(&report.quality_status).to_string();
1019    check.message = match &report.quality_status {
1020        QualityAnalysisStatus::NotRequested => {
1021            "quality metrics were not requested for this run".to_string()
1022        }
1023        QualityAnalysisStatus::NoData => {
1024            "quality was requested but no sample was available to measure".to_string()
1025        }
1026        QualityAnalysisStatus::WithheldByProjection => {
1027            "quality was withheld: the requested dimensions measure whole rows and only \
1028             some columns were profiled"
1029                .to_string()
1030        }
1031        QualityAnalysisStatus::Failed { .. } => "the quality computation failed".to_string(),
1032        QualityAnalysisStatus::Unrecorded | QualityAnalysisStatus::Computed => {
1033            "the report carries no quality assessment".to_string()
1034        }
1035    };
1036    check.status = CheckStatus::NotEvaluated(NotEvaluated::QualityUnavailable { quality_status });
1037    check
1038}
1039
1040fn aggregate_message(status: &CheckStatus, subject: &str) -> String {
1041    match status {
1042        CheckStatus::Passed => format!("{subject} meets the required minimum"),
1043        CheckStatus::Failed => format!("{subject} is below the required minimum"),
1044        CheckStatus::NotEvaluated(_) => format!(
1045            "{subject} was computed over part of the source, which bounds nothing about \
1046             the rest"
1047        ),
1048    }
1049}
1050
1051fn bounded_message(status: &CheckStatus, subject: &str) -> String {
1052    match status {
1053        CheckStatus::Passed => format!(
1054            "{subject} was computed over a sample, and its whole-source interval meets the \
1055             required minimum"
1056        ),
1057        CheckStatus::Failed => format!(
1058            "{subject} was computed over a sample, and its whole-source interval is below the \
1059             required minimum"
1060        ),
1061        CheckStatus::NotEvaluated(_) => format!(
1062            "{subject} was computed over a sample, and the required minimum lies within its \
1063             whole-source interval"
1064        ),
1065    }
1066}
1067
1068/// The interval a check decides on, from the report's bounds.
1069fn check_bounds(bounds: &ScoreBounds, interval: Option<ScoreInterval>) -> Option<CheckBounds> {
1070    interval.map(|interval| CheckBounds {
1071        lower: interval.lower,
1072        upper: interval.upper,
1073        confidence_level: bounds.confidence_level,
1074    })
1075}
1076
1077/// The `state` tag the report serializes for its quality status.
1078pub(crate) fn quality_status_name(status: &QualityAnalysisStatus) -> &'static str {
1079    match status {
1080        QualityAnalysisStatus::Computed => "computed",
1081        QualityAnalysisStatus::NotRequested => "not_requested",
1082        QualityAnalysisStatus::NoData => "no_data",
1083        QualityAnalysisStatus::WithheldByProjection => "withheld_by_projection",
1084        QualityAnalysisStatus::Failed { .. } => "failed",
1085        QualityAnalysisStatus::Unrecorded => "unrecorded",
1086    }
1087}
1088
1089/// Share of a column's values that are null, or `None` when no value was read.
1090/// Mirrors the Python binding's `ColumnProfile.null_percentage`.
1091fn null_percentage(profile: &ColumnProfile) -> Option<f64> {
1092    (profile.total_count > 0)
1093        .then(|| profile.null_count as f64 / profile.total_count as f64 * PERCENTAGE_MAX)
1094}
1095
1096/// How much of the source the scan itself covered.
1097///
1098/// Truncation is named ahead of sampling and sampling ahead of skipped
1099/// records, so the gap reported is the largest one.
1100fn scan_evidence(execution: &ExecutionMetadata) -> Evidence {
1101    let reason = if !execution.source_exhausted || execution.truncation_reason.is_some() {
1102        Some(EvidenceGap::Truncated)
1103    } else if execution.sampling_applied {
1104        Some(EvidenceGap::Sampled)
1105    } else if execution.error_count > 0 {
1106        Some(EvidenceGap::RecordsSkipped)
1107    } else {
1108        None
1109    };
1110    match reason {
1111        Some(reason) => Evidence::Incomplete { reason },
1112        None => Evidence::Complete,
1113    }
1114}
1115
1116/// Which part of the assessment a check reads, and so whose provenance
1117/// decides whether its number covers every scanned row.
1118#[derive(Debug, Clone, Copy)]
1119enum Provenance {
1120    /// The aggregate over every dimension.
1121    Overall,
1122    /// One dimension, through whichever components it is built from.
1123    Dimension(QualityDimension),
1124    /// One named component, as recorded in the assessment's confidence.
1125    Component(&'static str),
1126}
1127
1128/// The component labels a dimension is built from.
1129///
1130/// Uniqueness is the one dimension with two, and they can differ in
1131/// provenance: a full-stream row tracker counts duplicates over every row
1132/// while the key scan reads the retained sample.
1133fn dimension_components(dimension: QualityDimension) -> &'static [&'static str] {
1134    match dimension {
1135        QualityDimension::Completeness => &["completeness"],
1136        QualityDimension::Consistency => &["consistency"],
1137        QualityDimension::Uniqueness => &["key_uniqueness", "duplicate_rows"],
1138        QualityDimension::Accuracy => &["accuracy"],
1139        QualityDimension::Timeliness => &["timeliness"],
1140        QualityDimension::Validity => &["validity"],
1141        QualityDimension::Precision => &["precision"],
1142    }
1143}
1144
1145/// Whether the number a check reads came from every scanned row or from a
1146/// retained sample of them.
1147///
1148/// Resolved per component, so a check about one dimension is not downgraded by
1149/// another dimension's sampling.
1150fn quality_evidence(quality: Option<&QualityAssessment>, provenance: Provenance) -> Evidence {
1151    let Some(quality) = quality else {
1152        // No assessment to read; the check that called this reports the
1153        // absence itself, and there is no number for evidence to describe.
1154        return Evidence::Complete;
1155    };
1156    let Some(sampled) = quality.sampled_dimensions() else {
1157        return Evidence::Incomplete {
1158            reason: EvidenceGap::CoverageUnrecorded,
1159        };
1160    };
1161    let contains = |component: &str| sampled.iter().any(|label| label == component);
1162    let is_sampled = match provenance {
1163        // The overall score is a weighted average over the *assessed*
1164        // dimensions, so a sampled dimension the weights exclude does not
1165        // reach it. Reporting the aggregate as sampled because of one would
1166        // withhold a verdict the number does not depend on.
1167        Provenance::Overall => quality
1168            .metrics
1169            .assessed_dimensions()
1170            .into_iter()
1171            .flat_map(dimension_components)
1172            .any(|component| contains(component)),
1173        Provenance::Dimension(dimension) => dimension_components(dimension)
1174            .iter()
1175            .copied()
1176            .any(contains),
1177        Provenance::Component(component) => contains(component),
1178    };
1179    if is_sampled {
1180        Evidence::Incomplete {
1181            reason: EvidenceGap::QualitySampled,
1182        }
1183    } else {
1184        Evidence::Complete
1185    }
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use std::collections::HashMap;
1191
1192    use dataprof_core::{
1193        ColumnStats, DataSource, DataType, FileFormat, QualityScoreWeights, TruncationReason,
1194    };
1195    use dataprof_metrics::{QualityMetrics, UniquenessMetrics};
1196    use serde_json::{Value, json};
1197
1198    use super::*;
1199    use crate::ReportAssembler;
1200
1201    fn source() -> DataSource {
1202        DataSource::File {
1203            path: "drop.csv".to_string(),
1204            format: FileFormat::Csv,
1205            size_bytes: 256,
1206            modified_at: None,
1207            parquet_metadata: None,
1208        }
1209    }
1210
1211    fn column(name: &str, total: usize, nulls: usize) -> ColumnProfile {
1212        ColumnProfile {
1213            name: name.to_string(),
1214            data_type: DataType::String,
1215            null_count: nulls,
1216            total_count: total,
1217            unique_count: None,
1218            unique_count_is_approximate: None,
1219            unique_count_lower_bound: None,
1220            invalid_count: None,
1221            type_homogeneity: None,
1222            locale_number_count: None,
1223            stats: ColumnStats::None,
1224            patterns: None,
1225        }
1226    }
1227
1228    /// Two columns: `id` clean, `note` one-fifth null.
1229    fn sample_data() -> HashMap<String, Vec<String>> {
1230        let ids = (1..=5).map(|n| n.to_string()).collect::<Vec<_>>();
1231        let notes = vec!["a", "b", "c", "d", ""]
1232            .into_iter()
1233            .map(str::to_string)
1234            .collect::<Vec<_>>();
1235        HashMap::from([("id".to_string(), ids), ("note".to_string(), notes)])
1236    }
1237
1238    fn complete_report() -> ProfileReport {
1239        ReportAssembler::new(source(), ExecutionMetadata::new(5, 2, 10))
1240            .columns(vec![column("id", 5, 0), column("note", 5, 1)])
1241            .with_quality_data(sample_data())
1242            .build()
1243    }
1244
1245    fn truncated_report() -> ProfileReport {
1246        ReportAssembler::new(
1247            source(),
1248            ExecutionMetadata::new(5, 2, 10).with_truncation(TruncationReason::MaxRows(5)),
1249        )
1250        .columns(vec![column("id", 5, 0), column("note", 5, 1)])
1251        .with_quality_data(sample_data())
1252        .build()
1253    }
1254
1255    fn no_quality_report() -> ProfileReport {
1256        ReportAssembler::new(source(), ExecutionMetadata::new(5, 2, 10))
1257            .columns(vec![column("id", 5, 0), column("note", 5, 1)])
1258            .with_quality_data(sample_data())
1259            .skip_quality()
1260            .build()
1261    }
1262
1263    /// A report whose uniqueness dimension witnessed `duplicates` duplicate
1264    /// rows, truncated so the evidence behind it is partial.
1265    fn duplicate_report(duplicates: usize, approximate: bool) -> ProfileReport {
1266        let metrics = QualityMetrics {
1267            uniqueness: Some(UniquenessMetrics {
1268                duplicate_rows: duplicates,
1269                key_uniqueness: 100.0,
1270                high_cardinality_warning: false,
1271                rows_checked: 5,
1272                key_column: None,
1273                duplicate_rows_approximate: approximate,
1274            }),
1275            ..QualityMetrics::default()
1276        };
1277        ProfileReport::new(
1278            source(),
1279            vec![column("id", 5, 0)],
1280            ExecutionMetadata::new(5, 1, 10).with_truncation(TruncationReason::MaxRows(5)),
1281            Some(QualityAssessment::exact(metrics)),
1282        )
1283    }
1284
1285    fn check_for(result: &GateResult, code: CheckCode) -> &Check {
1286        result
1287            .checks
1288            .iter()
1289            .find(|check| check.code == code)
1290            .unwrap_or_else(|| panic!("no {code} check in {:?}", result.checks))
1291    }
1292
1293    fn column_check<'a>(result: &'a GateResult, name: &str) -> &'a Check {
1294        result
1295            .checks
1296            .iter()
1297            .find(|check| check.column.as_deref() == Some(name))
1298            .unwrap_or_else(|| panic!("no check for column {name}"))
1299    }
1300
1301    /// A report whose quality sample is smaller than the scan: the assembler
1302    /// bifurcates, and the dimensions computed from the reservoir are recorded
1303    /// as sampled while the ones from exact counters are not.
1304    ///
1305    /// This is the ordinary large-file shape, not an edge case. It is also the
1306    /// one an execution-metadata-only gate gets wrong: the source *was*
1307    /// exhausted and no row sampler ran, so `source_exhausted` and
1308    /// `sampling_applied` both say the scan covered everything.
1309    fn reservoir_report() -> ProfileReport {
1310        ReportAssembler::new(source(), ExecutionMetadata::new(100, 2, 10))
1311            .columns(vec![column("id", 100, 0), column("note", 100, 20)])
1312            .with_quality_data(sample_data())
1313            .build()
1314    }
1315
1316    #[test]
1317    fn a_reservoir_backed_score_is_decided_on_its_whole_source_interval() {
1318        let report = reservoir_report();
1319        assert!(report.execution.source_exhausted);
1320        assert!(!report.execution.sampling_applied);
1321        let quality = report.quality.as_ref().expect("quality was computed");
1322        let sampled = quality
1323            .sampled_dimensions()
1324            .expect("a profiling run records its provenance");
1325        assert!(
1326            !sampled.is_empty(),
1327            "the assembler did not bifurcate; this test no longer reaches the \
1328             case it guards"
1329        );
1330        let interval = quality
1331            .score_bounds()
1332            .expect("a sampled assessment carries bounds")
1333            .overall_score
1334            .expect("every sampled dimension here is bounded");
1335        assert!(
1336            interval.lower < interval.upper && interval.upper < 100.0,
1337            "a five-value sample leaves room on both sides: {interval:?}"
1338        );
1339        let decide = |min: f64| {
1340            let result = QualityPolicy::new()
1341                .min_quality_score(min)
1342                .evaluate(&report)
1343                .unwrap();
1344            // The scan itself was complete; only the metric's own evidence is
1345            // not, and a decision on the interval does not pretend otherwise.
1346            assert_eq!(result.evidence, Evidence::Complete);
1347            let check = check_for(&result, CheckCode::MinQualityScore).clone();
1348            assert_eq!(
1349                check.evidence,
1350                Evidence::Incomplete {
1351                    reason: EvidenceGap::QualitySampled
1352                }
1353            );
1354            assert_eq!(
1355                check.bounds.map(|bounds| (bounds.lower, bounds.upper)),
1356                Some((interval.lower, interval.upper))
1357            );
1358            (result.verdict, check)
1359        };
1360
1361        let (verdict, _) = decide(interval.lower);
1362        assert_eq!(verdict, Verdict::Pass);
1363
1364        let (verdict, check) = decide((interval.lower + interval.upper) / 2.0);
1365        assert_eq!(verdict, Verdict::Inconclusive);
1366        assert_eq!(
1367            check.not_evaluated(),
1368            Some(&NotEvaluated::EvidenceIncomplete {
1369                gap: EvidenceGap::QualitySampled
1370            })
1371        );
1372
1373        let (verdict, _) = decide(interval.upper + 0.01);
1374        assert_eq!(verdict, Verdict::Fail);
1375    }
1376
1377    #[test]
1378    fn an_observed_scope_decides_on_the_sampled_score_itself() {
1379        let report = reservoir_report();
1380        let score = report.quality_score().unwrap();
1381        let result = QualityPolicy::new()
1382            .min_quality_score(score)
1383            .scope(PolicyScope::Observed)
1384            .evaluate(&report)
1385            .unwrap();
1386        let check = check_for(&result, CheckCode::MinQualityScore);
1387        assert_eq!(result.verdict, Verdict::Pass);
1388        assert_eq!(check.bounds, None);
1389    }
1390
1391    #[test]
1392    fn a_sampled_score_without_recorded_bounds_stays_unevaluated() {
1393        let mut report = reservoir_report();
1394        let quality = report.quality.take().unwrap();
1395        report.quality = Some(QualityAssessment::new(quality.metrics, quality.confidence));
1396
1397        let result = QualityPolicy::new()
1398            .min_quality_score(1.0)
1399            .evaluate(&report)
1400            .unwrap();
1401        assert_eq!(result.verdict, Verdict::Inconclusive);
1402        assert_eq!(check_for(&result, CheckCode::MinQualityScore).bounds, None);
1403    }
1404
1405    /// Provenance is resolved per component, so a dimension computed from
1406    /// exact counters is still decidable when another one was sampled.
1407    #[test]
1408    fn an_exactly_counted_dimension_survives_another_dimensions_sampling() {
1409        let report = reservoir_report();
1410        let sampled = report
1411            .quality
1412            .as_ref()
1413            .expect("quality was computed")
1414            .sampled_dimensions()
1415            .expect("a profiling run records its provenance");
1416        assert!(
1417            !sampled.iter().any(|label| label == "completeness"),
1418            "completeness is supposed to come from exact column counters"
1419        );
1420
1421        let result = QualityPolicy::new()
1422            .min_dimension_score(QualityDimension::Completeness, 1.0)
1423            .min_dimension_score(QualityDimension::Consistency, 1.0)
1424            .evaluate(&report)
1425            .unwrap();
1426
1427        assert_eq!(
1428            result
1429                .checks
1430                .iter()
1431                .find(|check| check.dimension == Some(QualityDimension::Completeness))
1432                .map(|check| check.evidence),
1433            Some(Evidence::Complete)
1434        );
1435        assert_eq!(
1436            result
1437                .checks
1438                .iter()
1439                .find(|check| check.dimension == Some(QualityDimension::Consistency))
1440                .map(|check| check.evidence),
1441            Some(Evidence::Incomplete {
1442                reason: EvidenceGap::QualitySampled
1443            })
1444        );
1445    }
1446
1447    /// A report read back from a document written before dataprof recorded
1448    /// provenance does not say whether its numbers cover every scanned row.
1449    ///
1450    /// The compat path used to answer `Exact` for those, which is a claim the
1451    /// document never made: a full-source policy would then rest a verdict on
1452    /// numbers that may have come from a sample. The Python reload path
1453    /// reports the same gap, so the two agree on legacy input.
1454    #[test]
1455    fn a_legacy_document_without_recorded_coverage_is_not_read_as_a_full_scan() {
1456        let document = serde_json::to_value(complete_report()).expect("serializes");
1457        let mut legacy = document.clone();
1458        // Pre-0.10 documents carried the metrics flat, with no confidence.
1459        legacy["quality"] = document["quality"]["metrics"].clone();
1460        let report: ProfileReport = serde_json::from_value(legacy).expect("legacy document loads");
1461
1462        assert_eq!(
1463            report
1464                .quality
1465                .as_ref()
1466                .expect("the metrics survived")
1467                .sampled_dimensions(),
1468            None
1469        );
1470
1471        let result = QualityPolicy::new()
1472            .min_quality_score(1.0)
1473            .evaluate(&report)
1474            .unwrap();
1475        assert_eq!(result.verdict, Verdict::Inconclusive);
1476        let check = check_for(&result, CheckCode::MinQualityScore);
1477        assert_eq!(
1478            check.evidence,
1479            Evidence::Incomplete {
1480                reason: EvidenceGap::CoverageUnrecorded
1481            }
1482        );
1483
1484        // Asked about what the metrics measured, the same report is decidable.
1485        let observed = QualityPolicy::new()
1486            .min_quality_score(1.0)
1487            .scope(PolicyScope::Observed)
1488            .evaluate(&report)
1489            .unwrap();
1490        assert_eq!(observed.verdict, Verdict::Pass);
1491    }
1492
1493    /// Weights say what reaches the aggregate, not what was measured.
1494    ///
1495    /// The overall score renormalizes over the assessed dimensions, so a
1496    /// sampled dimension the weights exclude cannot move it, and reporting the
1497    /// aggregate as sampled because of one would withhold a verdict the number
1498    /// does not depend on. The excluded dimension keeps its own provenance.
1499    #[test]
1500    fn a_zero_weighted_sampled_dimension_does_not_taint_the_overall_score() {
1501        let mut report = reservoir_report();
1502        let quality = report.quality.as_mut().expect("quality was computed");
1503        let sampled = quality
1504            .sampled_dimensions()
1505            .expect("a profiling run records its provenance");
1506        assert!(
1507            sampled.iter().any(|label| label == "consistency"),
1508            "consistency is supposed to come from the reservoir"
1509        );
1510        // Completeness is the one dimension here computed from exact column
1511        // counters, so weighting only it leaves an aggregate that no sampled
1512        // component reaches.
1513        quality.metrics.score_weights = QualityScoreWeights {
1514            completeness: 1.0,
1515            consistency: 0.0,
1516            uniqueness: 0.0,
1517            accuracy: 0.0,
1518            timeliness: 0.0,
1519            validity: 0.0,
1520            precision: 0.0,
1521        };
1522
1523        let result = QualityPolicy::new()
1524            .min_quality_score(1.0)
1525            .min_dimension_score(QualityDimension::Consistency, 1.0)
1526            .evaluate(&report)
1527            .unwrap();
1528
1529        // The aggregate no longer depends on the sampled dimension.
1530        assert_eq!(
1531            check_for(&result, CheckCode::MinQualityScore).evidence,
1532            Evidence::Complete
1533        );
1534        // The dimension itself still reports where its number came from.
1535        assert_eq!(
1536            check_for(&result, CheckCode::MinDimensionScore).evidence,
1537            Evidence::Incomplete {
1538                reason: EvidenceGap::QualitySampled
1539            }
1540        );
1541    }
1542
1543    /// A dimension excluded from the aggregate by a zero weight is still
1544    /// measured, and a gate reading its score still needs its provenance.
1545    ///
1546    /// Confidence used to be downgraded to `NotAssessed` whenever the
1547    /// *weighted* set was empty, which erased the sampling record for every
1548    /// dimension at once.
1549    #[test]
1550    fn zero_weights_do_not_erase_the_sampling_record() {
1551        let mut report = reservoir_report();
1552        let quality = report.quality.as_mut().expect("quality was computed");
1553        quality.metrics.score_weights = QualityScoreWeights {
1554            completeness: 0.0,
1555            consistency: 0.0,
1556            uniqueness: 0.0,
1557            accuracy: 0.0,
1558            timeliness: 0.0,
1559            validity: 0.0,
1560            precision: 0.0,
1561        };
1562        let reassessed =
1563            QualityAssessment::new(quality.metrics.clone(), quality.confidence.clone());
1564
1565        assert!(reassessed.metrics.assessed_dimensions().is_empty());
1566        assert!(
1567            reassessed
1568                .sampled_dimensions()
1569                .expect("provenance survives")
1570                .iter()
1571                .any(|label| label == "consistency"),
1572            "a measured dimension kept no record of coming from a sample"
1573        );
1574    }
1575
1576    #[test]
1577    fn met_requirements_pass() {
1578        let result = QualityPolicy::new()
1579            .min_quality_score(1.0)
1580            .max_null_percentage_any(50.0)
1581            .evaluate(&complete_report())
1582            .unwrap();
1583
1584        assert_eq!(result.verdict, Verdict::Pass);
1585        assert!(result.passed());
1586        assert_eq!(result.violations().count(), 0);
1587        assert_eq!(result.unevaluated().count(), 0);
1588    }
1589
1590    #[test]
1591    fn violated_requirement_fails() {
1592        let result = QualityPolicy::new()
1593            .max_null_percentage("note", 10.0)
1594            .evaluate(&complete_report())
1595            .unwrap();
1596
1597        assert_eq!(result.verdict, Verdict::Fail);
1598        assert!(!result.passed());
1599        let violation = result.violations().next().expect("one violation");
1600        assert_eq!(violation.code, CheckCode::MaxNullPercentage);
1601        assert_eq!(violation.column.as_deref(), Some("note"));
1602        assert_eq!(violation.observed, Some(MetricValue::Percentage(20.0)));
1603        assert_eq!(
1604            violation.expected,
1605            Expectation::AtMost {
1606                value: MetricValue::Percentage(10.0)
1607            }
1608        );
1609    }
1610
1611    /// The named limit decides its own column; the wildcard covers the rest.
1612    #[test]
1613    fn named_limit_overrides_the_wildcard() {
1614        let result = QualityPolicy::new()
1615            .max_null_percentage("note", 50.0)
1616            .max_null_percentage_any(0.0)
1617            .evaluate(&complete_report())
1618            .unwrap();
1619
1620        assert_eq!(result.verdict, Verdict::Pass);
1621        assert_eq!(result.checks.len(), 2);
1622        assert_eq!(
1623            column_check(&result, "note").expected,
1624            Expectation::AtMost {
1625                value: MetricValue::Percentage(50.0)
1626            }
1627        );
1628        assert_eq!(
1629            column_check(&result, "id").expected,
1630            Expectation::AtMost {
1631                value: MetricValue::Percentage(0.0)
1632            }
1633        );
1634    }
1635
1636    /// Thresholds are percentages. A caller who writes the 0..1 ratio instead
1637    /// would otherwise get a gate that silently never fires.
1638    #[test]
1639    fn thresholds_are_on_the_percentage_scale() {
1640        let result = QualityPolicy::new()
1641            .min_quality_score(0.9)
1642            .evaluate(&complete_report())
1643            .unwrap();
1644        assert_eq!(result.verdict, Verdict::Pass);
1645
1646        let err = QualityPolicy::new()
1647            .min_quality_score(101.0)
1648            .evaluate(&complete_report())
1649            .unwrap_err();
1650        assert_eq!(
1651            err,
1652            PolicyError::ThresholdOutOfRange {
1653                code: CheckCode::MinQualityScore,
1654                subject: None,
1655                value: 101.0,
1656            }
1657        );
1658        assert!(
1659            QualityPolicy::new()
1660                .max_null_percentage("id", f64::NAN)
1661                .validate()
1662                .is_err()
1663        );
1664    }
1665
1666    #[test]
1667    fn empty_policy_is_rejected_rather_than_passing_everything() {
1668        assert_eq!(
1669            QualityPolicy::new()
1670                .evaluate(&complete_report())
1671                .unwrap_err(),
1672            PolicyError::NoRequirements
1673        );
1674    }
1675
1676    /// A metric that was never analyzed is not zero and not a pass. The check
1677    /// names the recorded reason, so a pipeline can tell a misconfiguration
1678    /// from an incident.
1679    #[test]
1680    fn unanalyzed_quality_is_inconclusive_not_a_pass() {
1681        let result = QualityPolicy::new()
1682            .min_quality_score(90.0)
1683            .evaluate(&no_quality_report())
1684            .unwrap();
1685
1686        assert_eq!(result.verdict, Verdict::Inconclusive);
1687        assert!(!result.passed());
1688        let check = check_for(&result, CheckCode::MinQualityScore);
1689        assert_eq!(check.observed, None);
1690        assert_eq!(
1691            check.not_evaluated(),
1692            Some(&NotEvaluated::QualityUnavailable {
1693                quality_status: "not_requested".to_string(),
1694            })
1695        );
1696    }
1697
1698    /// `require_quality` is how a caller says the absence itself is a failure.
1699    #[test]
1700    fn require_quality_turns_absence_into_a_violation() {
1701        let result = QualityPolicy::new()
1702            .require_quality()
1703            .min_quality_score(90.0)
1704            .evaluate(&no_quality_report())
1705            .unwrap();
1706
1707        assert_eq!(result.verdict, Verdict::Fail);
1708        let required = check_for(&result, CheckCode::RequireMetric);
1709        assert!(required.is_violation());
1710        assert_eq!(required.expected, Expectation::Analyzed);
1711        assert_eq!(required.observed, None);
1712    }
1713
1714    #[test]
1715    fn require_dimension_fails_when_the_dimension_had_nothing_to_assess() {
1716        let result = QualityPolicy::new()
1717            .require_dimension(QualityDimension::Timeliness)
1718            .evaluate(&complete_report())
1719            .unwrap();
1720
1721        assert_eq!(result.verdict, Verdict::Fail);
1722        assert_eq!(
1723            check_for(&result, CheckCode::RequireMetric).dimension,
1724            Some(QualityDimension::Timeliness)
1725        );
1726    }
1727
1728    /// A dimension that was computed but had nothing to assess is reported as
1729    /// such, separately from quality never having been requested.
1730    #[test]
1731    fn unassessed_dimension_is_distinct_from_unrequested_quality() {
1732        let result = QualityPolicy::new()
1733            .min_dimension_score(QualityDimension::Timeliness, 90.0)
1734            .evaluate(&complete_report())
1735            .unwrap();
1736
1737        assert_eq!(result.verdict, Verdict::Inconclusive);
1738        assert_eq!(
1739            check_for(&result, CheckCode::MinDimensionScore).not_evaluated(),
1740            Some(&NotEvaluated::NotAssessed)
1741        );
1742    }
1743
1744    /// A column the report does not profile is not decided either way: the
1745    /// report does not record whether it was projected away or absent.
1746    #[test]
1747    fn unprofiled_column_is_not_evaluated() {
1748        let result = QualityPolicy::new()
1749            .max_null_percentage("absent", 0.0)
1750            .evaluate(&complete_report())
1751            .unwrap();
1752
1753        assert_eq!(result.verdict, Verdict::Inconclusive);
1754        assert_eq!(
1755            column_check(&result, "absent").not_evaluated(),
1756            Some(&NotEvaluated::ColumnNotProfiled)
1757        );
1758    }
1759
1760    /// The whole point of the scope: a ratio measured over a prefix of the
1761    /// source says nothing about the rows that were never read.
1762    #[test]
1763    fn a_ratio_over_a_partial_scan_cannot_pass_a_full_source_policy() {
1764        let report = truncated_report();
1765        let result = QualityPolicy::new()
1766            .max_null_percentage_any(50.0)
1767            .evaluate(&report)
1768            .unwrap();
1769
1770        assert_eq!(result.verdict, Verdict::Inconclusive);
1771        assert_eq!(
1772            result.evidence,
1773            Evidence::Incomplete {
1774                reason: EvidenceGap::Truncated
1775            }
1776        );
1777        let check = column_check(&result, "note");
1778        assert_eq!(
1779            check.not_evaluated(),
1780            Some(&NotEvaluated::EvidenceIncomplete {
1781                gap: EvidenceGap::Truncated
1782            })
1783        );
1784        // The number was still read; it is the generalization that is refused.
1785        assert_eq!(check.observed, Some(MetricValue::Percentage(20.0)));
1786
1787        // Asked about the rows that were actually read, the same report passes.
1788        let observed = QualityPolicy::new()
1789            .max_null_percentage_any(50.0)
1790            .scope(PolicyScope::Observed)
1791            .evaluate(&report)
1792            .unwrap();
1793        assert_eq!(observed.verdict, Verdict::Pass);
1794    }
1795
1796    /// Duplicates already seen do not stop being duplicates when more rows are
1797    /// read, so a partial scan can still fail a full-source requirement.
1798    #[test]
1799    fn a_witnessed_duplicate_fails_despite_a_partial_scan() {
1800        let result = QualityPolicy::new()
1801            .max_duplicate_rows(0)
1802            .evaluate(&duplicate_report(2, false))
1803            .unwrap();
1804
1805        assert_eq!(result.verdict, Verdict::Fail);
1806        let check = check_for(&result, CheckCode::MaxDuplicateRows);
1807        assert_eq!(check.observed, Some(MetricValue::Count(2)));
1808        assert_eq!(
1809            check.evidence,
1810            Evidence::Incomplete {
1811                reason: EvidenceGap::Truncated
1812            }
1813        );
1814    }
1815
1816    /// A clean prefix is not a clean source.
1817    #[test]
1818    fn no_duplicate_in_a_partial_scan_is_not_a_pass() {
1819        let result = QualityPolicy::new()
1820            .max_duplicate_rows(0)
1821            .evaluate(&duplicate_report(0, false))
1822            .unwrap();
1823
1824        assert_eq!(result.verdict, Verdict::Inconclusive);
1825        assert_eq!(
1826            check_for(&result, CheckCode::MaxDuplicateRows).not_evaluated(),
1827            Some(&NotEvaluated::EvidenceIncomplete {
1828                gap: EvidenceGap::Truncated
1829            })
1830        );
1831    }
1832
1833    /// An estimated count is not a duplicate anyone saw, so it witnesses
1834    /// nothing and settles neither direction.
1835    #[test]
1836    fn an_estimated_duplicate_count_settles_neither_direction() {
1837        let result = QualityPolicy::new()
1838            .max_duplicate_rows(0)
1839            .evaluate(&duplicate_report(2, true))
1840            .unwrap();
1841
1842        assert_eq!(result.verdict, Verdict::Inconclusive);
1843        let check = check_for(&result, CheckCode::MaxDuplicateRows);
1844        assert_eq!(
1845            check.not_evaluated(),
1846            Some(&NotEvaluated::EvidenceIncomplete {
1847                gap: EvidenceGap::Truncated
1848            })
1849        );
1850        // The gap is the estimate, not unread rows: the message said "the rows
1851        // that were not read" on a source read to the end (#819).
1852        assert_eq!(
1853            check.message,
1854            "the duplicate-row count is an estimate, which can neither witness \
1855             duplicates above the allowance nor rule them out"
1856        );
1857    }
1858
1859    /// A conclusive violation outranks an unevaluated requirement: the gate
1860    /// has an answer even though it could not check everything.
1861    #[test]
1862    fn a_violation_outranks_an_unevaluated_check() {
1863        let result = QualityPolicy::new()
1864            .max_duplicate_rows(0)
1865            .min_dimension_score(QualityDimension::Timeliness, 90.0)
1866            .evaluate(&duplicate_report(2, false))
1867            .unwrap();
1868
1869        assert_eq!(result.verdict, Verdict::Fail);
1870        assert_eq!(result.unevaluated().count(), 1);
1871    }
1872
1873    /// Evaluation order is fixed by the data, not by the order requirements
1874    /// were added, so the same policy always serializes the same document.
1875    #[test]
1876    fn evaluation_order_does_not_depend_on_insertion_order() {
1877        let report = complete_report();
1878        let forward = QualityPolicy::new()
1879            .max_null_percentage("note", 50.0)
1880            .max_null_percentage("id", 50.0)
1881            .evaluate(&report)
1882            .unwrap();
1883        let reverse = QualityPolicy::new()
1884            .max_null_percentage("id", 50.0)
1885            .max_null_percentage("note", 50.0)
1886            .evaluate(&report)
1887            .unwrap();
1888
1889        assert_eq!(forward, reverse);
1890        let columns: Vec<_> = forward
1891            .checks
1892            .iter()
1893            .map(|check| check.column.clone().unwrap())
1894            .collect();
1895        assert_eq!(columns, vec!["id".to_string(), "note".to_string()]);
1896    }
1897
1898    /// The serialized shape is the contract a CI system reads. Percentages
1899    /// carry the report's 2dp convention; counts stay whole.
1900    #[test]
1901    fn result_serializes_into_the_documented_shape() {
1902        let result = QualityPolicy::new()
1903            .max_null_percentage("note", 10.0)
1904            .evaluate(&complete_report())
1905            .unwrap();
1906
1907        assert_eq!(
1908            serde_json::to_value(&result).unwrap(),
1909            json!({
1910                "verdict": "fail",
1911                "scope": "full_source",
1912                "evidence": {"coverage": "complete"},
1913                "checks": [{
1914                    "code": "max_null_percentage",
1915                    "column": "note",
1916                    "expected": {"comparison": "at_most", "value": 10.0},
1917                    "observed": 20.0,
1918                    "scope": "full_source",
1919                    "evidence": {"coverage": "complete"},
1920                    "status": "failed",
1921                    "message": "this column's null percentage is above the allowance",
1922                }],
1923            })
1924        );
1925    }
1926
1927    #[test]
1928    fn an_unevaluated_check_serializes_its_reason() {
1929        let result = QualityPolicy::new()
1930            .min_quality_score(90.0)
1931            .evaluate(&no_quality_report())
1932            .unwrap();
1933        let check = &serde_json::to_value(&result).unwrap()["checks"][0];
1934
1935        assert_eq!(check["status"], Value::String("not_evaluated".to_string()));
1936        assert_eq!(
1937            check["reason"],
1938            Value::String("quality_unavailable".to_string())
1939        );
1940        assert_eq!(
1941            check["quality_status"],
1942            Value::String("not_requested".to_string())
1943        );
1944        assert!(check.get("observed").is_none());
1945    }
1946}