Skip to main content

dataprof_runtime/
findings.rs

1//! Structured, prioritized findings over a [`ProfileReport`] (#375).
2//!
3//! A report carries every metric, and says nothing about which of them
4//! deserves attention. Answering that by hand means inventing thresholds at
5//! every call site. [`FindingPolicy::evaluate`] (or [`ProfileReport::findings`]
6//! with the default thresholds) turns the metrics a report already holds into
7//! a short, deterministic list of [`Finding`]s.
8//!
9//! Findings are interpretation, not data cleaning: each one names what was
10//! observed and the evidence behind it, and none carries a raw cell value.
11//!
12//! # Computed, not stored
13//!
14//! Findings are derived from the report on demand and are not part of the
15//! serialized report. A report read back from its document yields the same
16//! findings as the report that wrote it, and changing a threshold does not
17//! require re-profiling or rewriting stored reports.
18//!
19//! # Absence is not a clean result
20//!
21//! A rule whose input the report does not carry, such as patterns that were
22//! never detected or a quality assessment that was not requested, produces no
23//! finding, and is listed in [`FindingsResult::not_evaluated`] with the reason.
24//! An empty `findings` list means "looked, found nothing" only for the rules
25//! that are not listed there.
26//!
27//! # Scope
28//!
29//! Findings describe the rows the report read. When the scan stopped early or
30//! sampled, a [`FindingCode::PartialScan`] finding says so; the other findings
31//! are not withheld, because what was observed is still true of those rows.
32//! Use a [`QualityPolicy`](crate::QualityPolicy) for pass/fail decisions about
33//! the whole source.
34
35use std::collections::BTreeMap;
36use std::fmt;
37
38use dataprof_core::serde_helpers::rounded_2;
39use dataprof_core::{ColumnProfile, DataType, PatternCategory};
40use dataprof_metrics::QualityAssessment;
41
42use crate::profile_report::ProfileReport;
43use crate::quality_gate::{
44    Evidence, EvidenceGap, Provenance, quality_status_name, sampled_component_evidence,
45};
46
47/// Default for [`FindingPolicy::null_heavy_percentage`]. Matches the
48/// `null-heavy` flag in the Python binding's `to_llm_context()`.
49pub const DEFAULT_NULL_HEAVY_PERCENTAGE: f64 = 20.0;
50
51/// Default for [`FindingPolicy::mixed_types_percentage`]. Matches the
52/// `mixed types` flag in the Python binding's `to_llm_context()`.
53pub const DEFAULT_MIXED_TYPES_PERCENTAGE: f64 = 5.0;
54
55/// Pattern confidence a detection needs before a finding reports it: the
56/// threshold validity scoring and the report summaries already use.
57const MIN_PATTERN_CONFIDENCE: f64 = 0.5;
58
59/// Identifier patterns that name a person. The rest of the identifier
60/// category (UUIDs, product codes, VAT numbers) identifies things.
61const PERSONAL_IDENTIFIER_PATTERNS: [&str; 2] = ["Codice Fiscale (IT)", "SSN (US)"];
62
63/// How much attention a finding asks for.
64///
65/// Declared order is priority order: warnings sort first.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
67#[serde(rename_all = "snake_case")]
68#[non_exhaustive]
69pub enum Severity {
70    /// Likely a defect in the data or in how it was read.
71    Warning,
72    /// Worth knowing when reading the report; often intended.
73    Info,
74}
75
76impl fmt::Display for Severity {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::Warning => write!(f, "warning"),
80            Self::Info => write!(f, "info"),
81        }
82    }
83}
84
85/// Stable identifier for the rule a finding came from.
86///
87/// Part of the contract: a consumer matches on these, so they do not change
88/// when the prose does. Declared in alphabetical order, which is the order
89/// findings of equal severity sort in.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
91#[serde(rename_all = "snake_case")]
92#[non_exhaustive]
93pub enum FindingCode {
94    /// Every value of a column is null.
95    AllNull,
96    /// Every non-null value of a column is the same one.
97    ConstantColumn,
98    /// The source holds exact duplicate rows.
99    DuplicateRows,
100    /// Date values lie after the time the report was produced.
101    FutureDates,
102    /// At least half of a column's text values are numbers written with a
103    /// decimal comma or digit-group separators, which no numeric statistic
104    /// includes.
105    LocaleNumbers,
106    /// A column's values split across lexical types.
107    MixedTypes,
108    /// A column's null share is at or above the policy threshold.
109    NullHeavy,
110    /// The scan stopped early or sampled rows.
111    PartialScan,
112    /// Rows had a different field count from the header and were recovered.
113    RaggedRows,
114    /// Errors were counted while reading the source.
115    RecordsSkipped,
116    /// A column matches a pattern for personal or financial data.
117    SensitivePattern,
118    /// Start dates fall after their paired end dates.
119    TemporalOrderViolations,
120    /// A CSV source ended inside a quoted field.
121    UnterminatedQuote,
122}
123
124impl FindingCode {
125    /// The severity every finding with this code carries.
126    pub fn severity(self) -> Severity {
127        match self {
128            Self::AllNull
129            | Self::DuplicateRows
130            | Self::FutureDates
131            | Self::LocaleNumbers
132            | Self::MixedTypes
133            | Self::NullHeavy
134            | Self::RaggedRows
135            | Self::RecordsSkipped
136            | Self::TemporalOrderViolations
137            | Self::UnterminatedQuote => Severity::Warning,
138            Self::ConstantColumn | Self::PartialScan | Self::SensitivePattern => Severity::Info,
139        }
140    }
141
142    /// A fixed sentence naming what the rule observed.
143    ///
144    /// Deliberately free of numbers, as the gate's messages are: the evidence
145    /// carries those, so the Rust and Python layers cannot drift on rounding
146    /// inside a string.
147    fn summary(self) -> &'static str {
148        match self {
149            Self::AllNull => "every value in this column is null",
150            Self::ConstantColumn => "every non-null value in this column is the same",
151            Self::DuplicateRows => "the source contains exact duplicate rows",
152            Self::FutureDates => "some date values lie in the future",
153            Self::LocaleNumbers => {
154                "at least half of the text values in this column are numbers written with a \
155                 decimal comma or digit grouping, so no numeric statistic includes them"
156            }
157            Self::MixedTypes => "this column's values are split across lexical types",
158            Self::NullHeavy => "this column's null share is at or above the threshold",
159            Self::PartialScan => {
160                "only part of the source was read; findings describe the rows that were"
161            }
162            Self::RaggedRows => {
163                "some rows had a different field count from the header and were recovered"
164            }
165            Self::RecordsSkipped => "errors were counted while reading the source",
166            Self::SensitivePattern => {
167                "values in this column match a pattern for personal or financial data"
168            }
169            Self::TemporalOrderViolations => "some start dates fall after their paired end dates",
170            Self::UnterminatedQuote => {
171                "the source ends inside a quoted field, so its last record may hold several rows"
172            }
173        }
174    }
175}
176
177impl fmt::Display for FindingCode {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        let name = match self {
180            Self::AllNull => "all_null",
181            Self::ConstantColumn => "constant_column",
182            Self::DuplicateRows => "duplicate_rows",
183            Self::FutureDates => "future_dates",
184            Self::LocaleNumbers => "locale_numbers",
185            Self::MixedTypes => "mixed_types",
186            Self::NullHeavy => "null_heavy",
187            Self::PartialScan => "partial_scan",
188            Self::RaggedRows => "ragged_rows",
189            Self::RecordsSkipped => "records_skipped",
190            Self::SensitivePattern => "sensitive_pattern",
191            Self::TemporalOrderViolations => "temporal_order_violations",
192            Self::UnterminatedQuote => "unterminated_quote",
193        };
194        write!(f, "{name}")
195    }
196}
197
198/// One value a finding read from the report, or a threshold it applied.
199///
200/// Percentages follow the report's 2dp serialization convention; counts are
201/// whole numbers and are not widened into rounded floats.
202#[derive(Debug, Clone, PartialEq, serde::Serialize)]
203#[serde(untagged)]
204#[non_exhaustive]
205pub enum EvidenceValue {
206    /// A count of rows or values.
207    Count(usize),
208    /// A 0..=100 percentage.
209    Percentage(#[serde(serialize_with = "dataprof_core::serde_helpers::round_2")] f64),
210    /// A name from the report: a lexical type, a pattern, a reason.
211    Text(String),
212    /// A yes/no property of the evidence.
213    Flag(bool),
214}
215
216/// Something in the report that deserves attention.
217#[derive(Debug, Clone, PartialEq, serde::Serialize)]
218#[non_exhaustive]
219pub struct Finding {
220    /// Which rule produced it.
221    pub code: FindingCode,
222    /// How much attention it asks for. Always `code.severity()`.
223    pub severity: Severity,
224    /// The column it concerns, absent for a finding about the whole report.
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub column: Option<String>,
227    /// The metric values and thresholds that caused it, by name. Never a raw
228    /// cell value.
229    pub evidence: BTreeMap<String, EvidenceValue>,
230    /// A fixed sentence naming what was observed.
231    pub summary: String,
232}
233
234/// Why a rule produced no finding without having looked.
235#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
236#[serde(tag = "reason", rename_all = "snake_case")]
237#[non_exhaustive]
238pub enum NotEvaluatedReason {
239    /// The report carries no quality assessment, for this recorded reason.
240    QualityUnavailable {
241        /// The report's `quality_status` state, verbatim.
242        quality_status: String,
243    },
244    /// Quality was computed, but the dimension the rule reads had nothing to
245    /// assess in this run.
246    NotAssessed,
247    /// The number the rule reads is an estimate, which witnesses nothing: an
248    /// estimated duplicate count is not evidence that a duplicate exists.
249    Estimated,
250    /// The count the rule reads is zero, but it came from the retained quality
251    /// sample rather than every scanned row, so it rules nothing out for the
252    /// rows the sample left behind. A nonzero count is still reported.
253    Sampled,
254    /// The report does not record what the rule needs to tell a zero from an
255    /// unmeasured value: a document written before dataprof recorded it
256    /// (ragged rows before 0.10, quality sample coverage before 0.12).
257    Unrecorded,
258    /// The metric the rule reads was not computed for these columns: the
259    /// metric pack was not selected, or the column type does not carry it.
260    NotComputed,
261    /// These columns had no values for the rule to look at.
262    NoValues,
263}
264
265impl NotEvaluatedReason {
266    /// Position in the declared order, which is the order entries sort in.
267    fn rank(&self) -> u8 {
268        match self {
269            Self::QualityUnavailable { .. } => 0,
270            Self::NotAssessed => 1,
271            Self::Estimated => 2,
272            Self::Sampled => 3,
273            Self::Unrecorded => 4,
274            Self::NotComputed => 5,
275            Self::NoValues => 6,
276        }
277    }
278}
279
280/// A rule that could not be evaluated, and why.
281#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
282#[non_exhaustive]
283pub struct UnevaluatedRule {
284    /// The rule.
285    pub code: FindingCode,
286    /// Why it was not evaluated.
287    #[serde(flatten)]
288    pub reason: NotEvaluatedReason,
289    /// For a column rule, the columns it could not look at, in report order.
290    /// Empty for a rule about the whole report.
291    #[serde(skip_serializing_if = "Vec::is_empty")]
292    pub columns: Vec<String>,
293}
294
295/// What [`FindingPolicy::evaluate`] found.
296#[derive(Debug, Clone, PartialEq, serde::Serialize)]
297#[non_exhaustive]
298pub struct FindingsResult {
299    /// Every finding, most severe first. Within a severity: by code, then
300    /// report-level before column-level, then by column position in the
301    /// report, then by pattern name.
302    pub findings: Vec<Finding>,
303    /// Every rule that could not look, by code then reason. A rule listed
304    /// here for some columns was still evaluated for the others.
305    pub not_evaluated: Vec<UnevaluatedRule>,
306}
307
308/// A finding threshold that cannot be applied as written.
309#[derive(Debug, Clone, PartialEq)]
310#[non_exhaustive]
311pub enum FindingPolicyError {
312    /// A threshold fell outside `(0, 100]` at the 2dp precision it is applied
313    /// at, or was not a finite number. Percentages are on the report's 0..100
314    /// scale, not 0..1 ratios, and a zero threshold would report every column.
315    ThresholdOutOfRange {
316        /// The setting that carried it.
317        setting: &'static str,
318        /// The value as supplied.
319        value: f64,
320    },
321}
322
323impl fmt::Display for FindingPolicyError {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        match self {
326            Self::ThresholdOutOfRange { setting, value } => write!(
327                f,
328                "{setting} must be a percentage above 0 and at most 100 at two decimal \
329                 places, got {value}"
330            ),
331        }
332    }
333}
334
335impl std::error::Error for FindingPolicyError {}
336
337/// The thresholds findings apply.
338///
339/// Separate from the profiler's configuration on purpose: findings interpret
340/// a finished report, including one read back from disk that no profiler
341/// configuration accompanies.
342#[derive(Debug, Clone, PartialEq)]
343pub struct FindingPolicy {
344    null_heavy_percentage: f64,
345    mixed_types_percentage: f64,
346}
347
348impl Default for FindingPolicy {
349    fn default() -> Self {
350        Self {
351            null_heavy_percentage: DEFAULT_NULL_HEAVY_PERCENTAGE,
352            mixed_types_percentage: DEFAULT_MIXED_TYPES_PERCENTAGE,
353        }
354    }
355}
356
357impl FindingPolicy {
358    /// The default thresholds.
359    pub fn new() -> Self {
360        Self::default()
361    }
362
363    /// Report a column as `null_heavy` when its null percentage, at the
364    /// report's 2dp precision, is at least `percentage`. The threshold is
365    /// applied at 2dp too, so the evidence states exactly what was compared.
366    pub fn null_heavy_percentage(mut self, percentage: f64) -> Self {
367        self.null_heavy_percentage = percentage;
368        self
369    }
370
371    /// Report a column as `mixed_types` when the values outside its dominant
372    /// lexical type make up at least `percentage` of those classified, both
373    /// at 2dp.
374    pub fn mixed_types_percentage(mut self, percentage: f64) -> Self {
375        self.mixed_types_percentage = percentage;
376        self
377    }
378
379    /// Check that every threshold can be applied, without needing a report.
380    pub fn validate(&self) -> Result<(), FindingPolicyError> {
381        for (setting, value) in [
382            ("null_heavy_percentage", self.null_heavy_percentage),
383            ("mixed_types_percentage", self.mixed_types_percentage),
384        ] {
385            let applied = rounded_2(value);
386            if !(value.is_finite() && applied > 0.0 && applied <= 100.0) {
387                return Err(FindingPolicyError::ThresholdOutOfRange { setting, value });
388            }
389        }
390        Ok(())
391    }
392
393    /// Derive the findings a report supports.
394    ///
395    /// The report is not modified and nothing is printed.
396    pub fn evaluate(&self, report: &ProfileReport) -> Result<FindingsResult, FindingPolicyError> {
397        self.validate()?;
398        Ok(self.collect(report))
399    }
400
401    /// The thresholds as applied: at the precision a percentage is compared
402    /// and reported at.
403    fn null_heavy_threshold(&self) -> f64 {
404        rounded_2(self.null_heavy_percentage)
405    }
406
407    fn mixed_types_threshold(&self) -> f64 {
408        rounded_2(self.mixed_types_percentage)
409    }
410
411    fn collect(&self, report: &ProfileReport) -> FindingsResult {
412        let mut collector = Collector::default();
413        scan_findings(report, &mut collector);
414        quality_findings(report, &mut collector);
415        for (index, column) in report.column_profiles.iter().enumerate() {
416            self.column_findings(index, column, &mut collector);
417        }
418        collector.finish()
419    }
420
421    fn column_findings(&self, index: usize, column: &ColumnProfile, out: &mut Collector) {
422        let name = column.name.as_str();
423        let non_null = column.total_count.saturating_sub(column.null_count);
424
425        if column.total_count == 0 {
426            out.skip(FindingCode::AllNull, NotEvaluatedReason::NoValues, name);
427            out.skip(FindingCode::NullHeavy, NotEvaluatedReason::NoValues, name);
428        } else if non_null == 0 {
429            out.column(
430                FindingCode::AllNull,
431                index,
432                name,
433                [
434                    ("null_count", EvidenceValue::Count(column.null_count)),
435                    ("total_count", EvidenceValue::Count(column.total_count)),
436                ],
437            );
438        } else {
439            // Compared at the serialized precision, so a report read back from
440            // its document lands on the same side of the threshold.
441            let null_percentage =
442                rounded_2(column.null_count as f64 / column.total_count as f64 * 100.0);
443            if null_percentage >= self.null_heavy_threshold() {
444                out.column(
445                    FindingCode::NullHeavy,
446                    index,
447                    name,
448                    [
449                        ("null_percentage", percentage(null_percentage)),
450                        ("threshold", percentage(self.null_heavy_threshold())),
451                    ],
452                );
453            }
454        }
455
456        match column.unique_count {
457            None => out.skip(
458                FindingCode::ConstantColumn,
459                NotEvaluatedReason::NotComputed,
460                name,
461            ),
462            Some(_) if non_null == 0 => out.skip(
463                FindingCode::ConstantColumn,
464                NotEvaluatedReason::NoValues,
465                name,
466            ),
467            // One value seen once is not a constant, just a single value.
468            Some(1) if non_null > 1 => out.column(
469                FindingCode::ConstantColumn,
470                index,
471                name,
472                [
473                    ("unique_count", EvidenceValue::Count(1)),
474                    ("non_null_count", EvidenceValue::Count(non_null)),
475                ],
476            ),
477            Some(_) => {}
478        }
479
480        locale_numbers(index, column, out);
481        self.mixed_types(index, column, non_null, out);
482        sensitive_patterns(index, column, non_null, out);
483    }
484
485    fn mixed_types(
486        &self,
487        index: usize,
488        column: &ColumnProfile,
489        non_null: usize,
490        out: &mut Collector,
491    ) {
492        let name = column.name.as_str();
493        // Mixing forms ("A1", "123") is what an identifier scheme does, not a
494        // defect: the same exemption the consistency dimension makes.
495        if column.data_type == DataType::Identifier {
496            return;
497        }
498        let Some(homogeneity) = column.type_homogeneity.as_ref() else {
499            out.skip(
500                FindingCode::MixedTypes,
501                NotEvaluatedReason::NotComputed,
502                name,
503            );
504            return;
505        };
506        let classified = homogeneity.classified_count();
507        let Some((dominant, dominant_count)) = homogeneity.dominant() else {
508            out.skip(FindingCode::MixedTypes, NotEvaluatedReason::NoValues, name);
509            return;
510        };
511        let outside = classified - dominant_count;
512        if outside == 0 {
513            return;
514        }
515        let outside_percentage = rounded_2(outside as f64 / classified as f64 * 100.0);
516        if outside_percentage < self.mixed_types_threshold() {
517            return;
518        }
519        out.column(
520            FindingCode::MixedTypes,
521            index,
522            name,
523            [
524                ("dominant_type", EvidenceValue::Text(dominant.to_string())),
525                (
526                    "dominant_percentage",
527                    percentage(dominant_count as f64 / classified as f64 * 100.0),
528                ),
529                ("threshold", percentage(self.mixed_types_threshold())),
530                // Shares are counted over the values the profiler retained; a
531                // classified count short of the non-null count says they were
532                // sampled.
533                ("classified_count", EvidenceValue::Count(classified)),
534                ("non_null_count", EvidenceValue::Count(non_null)),
535            ],
536        );
537    }
538}
539
540/// Report a column at least half of whose text values are numbers written in
541/// a locale format (#433).
542///
543/// Measured against the text values rather than the whole column, so it
544/// covers both ways these numbers go missing: a column of `1.234,56` typed
545/// `string` with no numeric statistics, and a numeric column whose
546/// decimal-comma values were counted as invalid. At least half, so that a free
547/// text column holding a stray `1,5` is not reported.
548fn locale_numbers(index: usize, column: &ColumnProfile, out: &mut Collector) {
549    let name = column.name.as_str();
550    // The exemption `mixed_types` makes: `1.234.567` in an identifier
551    // scheme is a code, not an amount.
552    if column.data_type == DataType::Identifier {
553        return;
554    }
555    let Some(homogeneity) = column.type_homogeneity.as_ref() else {
556        out.skip(
557            FindingCode::LocaleNumbers,
558            NotEvaluatedReason::NotComputed,
559            name,
560        );
561        return;
562    };
563    // Classified, but by a release that did not count these yet.
564    let Some(locale_numbers) = column.locale_number_count else {
565        out.skip(
566            FindingCode::LocaleNumbers,
567            NotEvaluatedReason::Unrecorded,
568            name,
569        );
570        return;
571    };
572    let classified = homogeneity.classified_count();
573    if classified == 0 {
574        out.skip(
575            FindingCode::LocaleNumbers,
576            NotEvaluatedReason::NoValues,
577            name,
578        );
579        return;
580    }
581    let text = homogeneity.text;
582    if locale_numbers == 0 || locale_numbers * 2 < text {
583        return;
584    }
585    out.column(
586        FindingCode::LocaleNumbers,
587        index,
588        name,
589        [
590            ("locale_number_count", EvidenceValue::Count(locale_numbers)),
591            ("text_count", EvidenceValue::Count(text)),
592            // Counted over the values the profiler retained, as in
593            // `mixed_types`.
594            ("classified_count", EvidenceValue::Count(classified)),
595            (
596                "non_null_count",
597                EvidenceValue::Count(column.total_count.saturating_sub(column.null_count)),
598            ),
599        ],
600    );
601}
602
603impl ProfileReport {
604    /// The findings this report supports under the default thresholds.
605    ///
606    /// Shorthand for `FindingPolicy::default().evaluate(self)`, which cannot
607    /// fail because the defaults are in range.
608    pub fn findings(&self) -> FindingsResult {
609        FindingPolicy::default().collect(self)
610    }
611}
612
613fn scan_findings(report: &ProfileReport, out: &mut Collector) {
614    let execution = &report.execution;
615    let partial = if !execution.source_exhausted || execution.truncation_reason.is_some() {
616        Some("truncated")
617    } else if execution.sampling_applied {
618        Some("sampled")
619    } else {
620        None
621    };
622    if let Some(reason) = partial {
623        out.report(
624            FindingCode::PartialScan,
625            [
626                ("reason", EvidenceValue::Text(reason.to_string())),
627                (
628                    "rows_processed",
629                    EvidenceValue::Count(execution.rows_processed),
630                ),
631            ],
632        );
633    }
634    if report.schema_version == 0 {
635        // Ragged rows were first counted by the release that introduced
636        // schema versioning (0.10, #452). An older document reads back with a
637        // defaulted zero that was never measured.
638        out.skip_report(FindingCode::RaggedRows, NotEvaluatedReason::Unrecorded);
639    } else if execution.ragged_row_count > 0 {
640        out.report(
641            FindingCode::RaggedRows,
642            [
643                (
644                    "ragged_row_count",
645                    EvidenceValue::Count(execution.ragged_row_count),
646                ),
647                (
648                    "rows_processed",
649                    EvidenceValue::Count(execution.rows_processed),
650                ),
651            ],
652        );
653    }
654    if execution.unterminated_quote == Some(true) {
655        out.report(
656            FindingCode::UnterminatedQuote,
657            [(
658                "rows_processed",
659                EvidenceValue::Count(execution.rows_processed),
660            )],
661        );
662    }
663    if execution.error_count > 0 {
664        out.report(
665            FindingCode::RecordsSkipped,
666            [("error_count", EvidenceValue::Count(execution.error_count))],
667        );
668    }
669}
670
671fn quality_findings(report: &ProfileReport, out: &mut Collector) {
672    const QUALITY_RULES: [FindingCode; 3] = [
673        FindingCode::DuplicateRows,
674        FindingCode::FutureDates,
675        FindingCode::TemporalOrderViolations,
676    ];
677    let Some(quality) = report.quality.as_ref() else {
678        let quality_status = quality_status_name(&report.quality_status).to_string();
679        for code in QUALITY_RULES {
680            out.skip_report(
681                code,
682                NotEvaluatedReason::QualityUnavailable {
683                    quality_status: quality_status.clone(),
684                },
685            );
686        }
687        return;
688    };
689
690    match quality
691        .metrics
692        .uniqueness
693        .as_ref()
694        .filter(|uniqueness| uniqueness.rows_checked > 0)
695    {
696        None => out.skip_report(FindingCode::DuplicateRows, NotEvaluatedReason::NotAssessed),
697        Some(uniqueness) if uniqueness.duplicate_rows_approximate => {
698            out.skip_report(FindingCode::DuplicateRows, NotEvaluatedReason::Estimated)
699        }
700        Some(uniqueness) if uniqueness.duplicate_rows > 0 => out.report(
701            FindingCode::DuplicateRows,
702            [
703                (
704                    "duplicate_rows",
705                    EvidenceValue::Count(uniqueness.duplicate_rows),
706                ),
707                (
708                    "rows_checked",
709                    EvidenceValue::Count(uniqueness.rows_checked),
710                ),
711            ],
712        ),
713        Some(_) => skip_unless_complete(out, FindingCode::DuplicateRows, quality, "duplicate_rows"),
714    }
715
716    let timeliness = quality.metrics.timeliness.as_ref();
717    match timeliness.filter(|timeliness| timeliness.date_values_checked > 0) {
718        None => out.skip_report(FindingCode::FutureDates, NotEvaluatedReason::NotAssessed),
719        Some(timeliness) if timeliness.future_dates_count > 0 => out.report(
720            FindingCode::FutureDates,
721            [
722                (
723                    "future_dates_count",
724                    EvidenceValue::Count(timeliness.future_dates_count),
725                ),
726                (
727                    "date_values_checked",
728                    EvidenceValue::Count(timeliness.date_values_checked),
729                ),
730            ],
731        ),
732        Some(_) => skip_unless_complete(out, FindingCode::FutureDates, quality, "timeliness"),
733    }
734    match timeliness.filter(|timeliness| timeliness.temporal_pairs_checked > 0) {
735        None => out.skip_report(
736            FindingCode::TemporalOrderViolations,
737            NotEvaluatedReason::NotAssessed,
738        ),
739        Some(timeliness) if timeliness.temporal_violations > 0 => out.report(
740            FindingCode::TemporalOrderViolations,
741            [
742                (
743                    "temporal_violations",
744                    EvidenceValue::Count(timeliness.temporal_violations),
745                ),
746                (
747                    "temporal_pairs_checked",
748                    EvidenceValue::Count(timeliness.temporal_pairs_checked),
749                ),
750            ],
751        ),
752        Some(_) => skip_unless_complete(
753            out,
754            FindingCode::TemporalOrderViolations,
755            quality,
756            "timeliness",
757        ),
758    }
759}
760
761/// A zero count is a clean result only when it covers every scanned row.
762///
763/// A count from the retained quality sample, or from a report that does not
764/// record where its counts came from, rules nothing out for the rows it did
765/// not see, so the rule is listed rather than read as clean. A nonzero count
766/// never reaches here: rows already witnessed stay witnessed. Coverage is
767/// resolved by the quality gate's rule, so a finding and a check never
768/// disagree about one component.
769fn skip_unless_complete(
770    out: &mut Collector,
771    code: FindingCode,
772    quality: &QualityAssessment,
773    component: &'static str,
774) {
775    let coverage = sampled_component_evidence(
776        quality.sampled_dimensions().as_deref(),
777        &quality.unlisted_components(),
778        &quality.metrics.assessed_dimensions(),
779        Provenance::Component(component),
780    );
781    match coverage {
782        Evidence::Complete => {}
783        Evidence::Incomplete {
784            reason: EvidenceGap::QualitySampled,
785        } => out.skip_report(code, NotEvaluatedReason::Sampled),
786        Evidence::Incomplete { .. } => out.skip_report(code, NotEvaluatedReason::Unrecorded),
787    }
788}
789
790/// Whether a detected pattern names personal or financial data.
791fn is_sensitive(category: &PatternCategory, name: &str) -> bool {
792    matches!(
793        category,
794        PatternCategory::Contact | PatternCategory::Financial
795    ) || PERSONAL_IDENTIFIER_PATTERNS.contains(&name)
796}
797
798fn sensitive_patterns(index: usize, column: &ColumnProfile, non_null: usize, out: &mut Collector) {
799    let Some(patterns) = column.patterns.as_ref() else {
800        out.skip(
801            FindingCode::SensitivePattern,
802            NotEvaluatedReason::NotComputed,
803            &column.name,
804        );
805        return;
806    };
807    if non_null == 0 {
808        // Detection ran over nothing, which is no evidence either way.
809        out.skip(
810            FindingCode::SensitivePattern,
811            NotEvaluatedReason::NoValues,
812            &column.name,
813        );
814        return;
815    }
816    for pattern in patterns {
817        if pattern.confidence < MIN_PATTERN_CONFIDENCE
818            || !is_sensitive(&pattern.category, &pattern.name)
819        {
820            continue;
821        }
822        out.push(
823            Finding {
824                code: FindingCode::SensitivePattern,
825                severity: FindingCode::SensitivePattern.severity(),
826                column: Some(column.name.clone()),
827                evidence: evidence([
828                    ("pattern", EvidenceValue::Text(pattern.name.clone())),
829                    (
830                        "category",
831                        EvidenceValue::Text(pattern.category.to_string()),
832                    ),
833                    ("match_percentage", percentage(pattern.match_percentage)),
834                ]),
835                summary: FindingCode::SensitivePattern.summary().to_string(),
836            },
837            Some(index),
838            pattern.name.clone(),
839        );
840    }
841}
842
843/// Percentage evidence at the precision it serializes with, so a finding
844/// compares equal to one rebuilt from its own document, and to what the
845/// Python layer holds.
846fn percentage(value: f64) -> EvidenceValue {
847    EvidenceValue::Percentage(rounded_2(value))
848}
849
850fn evidence<const N: usize>(
851    entries: [(&'static str, EvidenceValue); N],
852) -> BTreeMap<String, EvidenceValue> {
853    entries
854        .into_iter()
855        .map(|(name, value)| (name.to_string(), value))
856        .collect()
857}
858
859/// A finding with the keys it sorts by.
860struct Ranked {
861    finding: Finding,
862    /// `None` for a report-level finding, which sorts before column findings.
863    column_index: Option<usize>,
864    /// Breaks ties between findings of one code on one column.
865    detail: String,
866}
867
868#[derive(Default)]
869struct Collector {
870    findings: Vec<Ranked>,
871    not_evaluated: Vec<UnevaluatedRule>,
872}
873
874impl Collector {
875    fn push(&mut self, finding: Finding, column_index: Option<usize>, detail: String) {
876        self.findings.push(Ranked {
877            finding,
878            column_index,
879            detail,
880        });
881    }
882
883    fn report<const N: usize>(
884        &mut self,
885        code: FindingCode,
886        entries: [(&'static str, EvidenceValue); N],
887    ) {
888        self.push(
889            Finding {
890                code,
891                severity: code.severity(),
892                column: None,
893                evidence: evidence(entries),
894                summary: code.summary().to_string(),
895            },
896            None,
897            String::new(),
898        );
899    }
900
901    fn column<const N: usize>(
902        &mut self,
903        code: FindingCode,
904        index: usize,
905        name: &str,
906        entries: [(&'static str, EvidenceValue); N],
907    ) {
908        self.push(
909            Finding {
910                code,
911                severity: code.severity(),
912                column: Some(name.to_string()),
913                evidence: evidence(entries),
914                summary: code.summary().to_string(),
915            },
916            Some(index),
917            String::new(),
918        );
919    }
920
921    fn skip_report(&mut self, code: FindingCode, reason: NotEvaluatedReason) {
922        self.not_evaluated.push(UnevaluatedRule {
923            code,
924            reason,
925            columns: Vec::new(),
926        });
927    }
928
929    /// Record a column a rule could not look at, joining the entry for the
930    /// same rule and reason so each pair is listed once.
931    fn skip(&mut self, code: FindingCode, reason: NotEvaluatedReason, column: &str) {
932        match self
933            .not_evaluated
934            .iter_mut()
935            .find(|entry| entry.code == code && entry.reason == reason)
936        {
937            Some(entry) => entry.columns.push(column.to_string()),
938            None => self.not_evaluated.push(UnevaluatedRule {
939                code,
940                reason,
941                columns: vec![column.to_string()],
942            }),
943        }
944    }
945
946    fn finish(mut self) -> FindingsResult {
947        self.findings.sort_by(|left, right| {
948            (left.finding.severity, left.finding.code)
949                .cmp(&(right.finding.severity, right.finding.code))
950                .then_with(|| left.column_index.cmp(&right.column_index))
951                .then_with(|| left.detail.cmp(&right.detail))
952        });
953        // Stable, so the columns inside one entry keep report order.
954        self.not_evaluated.sort_by(|left, right| {
955            (left.code, left.reason.rank()).cmp(&(right.code, right.reason.rank()))
956        });
957        FindingsResult {
958            findings: self
959                .findings
960                .into_iter()
961                .map(|ranked| ranked.finding)
962                .collect(),
963            not_evaluated: self.not_evaluated,
964        }
965    }
966}
967
968#[cfg(test)]
969mod tests {
970    use dataprof_core::{
971        ColumnStats, DataSource, ExecutionMetadata, FileFormat, Pattern, TruncationReason,
972        TypeHomogeneity,
973    };
974    use dataprof_metrics::{
975        MetricConfidence, QualityAssessment, QualityMetrics, TimelinessMetrics, UniquenessMetrics,
976    };
977
978    use super::*;
979
980    fn source() -> DataSource {
981        DataSource::File {
982            path: "drop.csv".to_string(),
983            format: FileFormat::Csv,
984            size_bytes: 256,
985            modified_at: None,
986            parquet_metadata: None,
987        }
988    }
989
990    fn column(name: &str, total: usize, nulls: usize) -> ColumnProfile {
991        ColumnProfile {
992            name: name.to_string(),
993            data_type: DataType::String,
994            null_count: nulls,
995            total_count: total,
996            unique_count: None,
997            unique_count_is_approximate: None,
998            unique_count_lower_bound: None,
999            invalid_count: None,
1000            type_homogeneity: None,
1001            locale_number_count: None,
1002            stats: ColumnStats::None,
1003            patterns: None,
1004        }
1005    }
1006
1007    fn pattern(name: &str, category: PatternCategory, confidence: f64) -> Pattern {
1008        Pattern {
1009            name: name.to_string(),
1010            regex: String::new(),
1011            match_count: 10,
1012            match_percentage: 100.0,
1013            category,
1014            confidence,
1015        }
1016    }
1017
1018    fn report(columns: Vec<ColumnProfile>, execution: ExecutionMetadata) -> ProfileReport {
1019        ProfileReport::new(source(), columns, execution, None)
1020    }
1021
1022    fn codes(result: &FindingsResult) -> Vec<(FindingCode, Option<&str>)> {
1023        result
1024            .findings
1025            .iter()
1026            .map(|finding| (finding.code, finding.column.as_deref()))
1027            .collect()
1028    }
1029
1030    #[test]
1031    fn an_estimated_duplicate_count_witnesses_nothing() {
1032        let metrics = QualityMetrics {
1033            uniqueness: Some(UniquenessMetrics {
1034                duplicate_rows: 1_200,
1035                key_uniqueness: 100.0,
1036                high_cardinality_warning: false,
1037                rows_checked: 5_000_000,
1038                key_column: None,
1039                key_values_checked: None,
1040                duplicate_rows_approximate: true,
1041            }),
1042            ..QualityMetrics::default()
1043        };
1044        let report = ProfileReport::new(
1045            source(),
1046            vec![],
1047            ExecutionMetadata::new(5_000_000, 0, 10),
1048            Some(QualityAssessment::exact(metrics)),
1049        );
1050
1051        let result = report.findings();
1052
1053        assert!(
1054            !codes(&result).contains(&(FindingCode::DuplicateRows, None)),
1055            "an HLL estimate reported as a duplicate: {:?}",
1056            result.findings
1057        );
1058        assert!(result.not_evaluated.contains(&UnevaluatedRule {
1059            code: FindingCode::DuplicateRows,
1060            reason: NotEvaluatedReason::Estimated,
1061            columns: vec![],
1062        }));
1063    }
1064
1065    #[test]
1066    fn the_threshold_is_compared_at_the_serialized_precision() {
1067        // 4,999 of 25,000 is 19.996%, which the report serializes as 20.0. A
1068        // full-precision comparison would miss it here and report it once the
1069        // report was read back from its document.
1070        let report = report(
1071            vec![column("near", 25_000, 4_999)],
1072            ExecutionMetadata::new(25_000, 1, 10),
1073        );
1074
1075        let finding = report
1076            .findings()
1077            .findings
1078            .into_iter()
1079            .find(|finding| finding.code == FindingCode::NullHeavy)
1080            .expect("reported at the rounded value");
1081        assert_eq!(
1082            finding.evidence["null_percentage"],
1083            EvidenceValue::Percentage(20.0)
1084        );
1085    }
1086
1087    #[test]
1088    fn only_confident_personal_or_financial_patterns_are_sensitive() {
1089        let mut profile = column("mixed_ids", 10, 0);
1090        profile.patterns = Some(vec![
1091            pattern("UUID", PatternCategory::Identifier, 0.9),
1092            pattern("SSN (US)", PatternCategory::Identifier, 0.9),
1093            pattern("IBAN", PatternCategory::Financial, 0.4),
1094            pattern("Email", PatternCategory::Contact, 0.8),
1095            pattern("IPv4", PatternCategory::Network, 0.9),
1096        ]);
1097        let result = report(vec![profile], ExecutionMetadata::new(10, 1, 10)).findings();
1098
1099        let reported: Vec<_> = result
1100            .findings
1101            .iter()
1102            .filter(|finding| finding.code == FindingCode::SensitivePattern)
1103            .map(|finding| finding.evidence["pattern"].clone())
1104            .collect();
1105        // Ordered by pattern name within the column, not by detection order.
1106        assert_eq!(
1107            reported,
1108            vec![
1109                EvidenceValue::Text("Email".to_string()),
1110                EvidenceValue::Text("SSN (US)".to_string()),
1111            ]
1112        );
1113    }
1114
1115    #[test]
1116    fn evidence_holds_the_value_it_serializes() {
1117        // A percentage kept at full precision in memory and rounded only on
1118        // the way out would make two findings that serialize identically
1119        // compare unequal, depending on who wrote the document they came from.
1120        let mut profile = column("email", 3, 0);
1121        profile.type_homogeneity = Some(TypeHomogeneity {
1122            numeric: 1,
1123            date: 0,
1124            boolean: 0,
1125            text: 2,
1126        });
1127        let mut email = pattern("Email", PatternCategory::Contact, 0.8);
1128        email.match_percentage = 200.0 / 3.0;
1129        profile.patterns = Some(vec![email]);
1130        let result = report(vec![profile], ExecutionMetadata::new(3, 1, 10)).findings();
1131
1132        let serialized: FindingsResult = {
1133            let value = serde_json::to_value(&result).expect("serializes");
1134            let mut rebuilt = result.clone();
1135            for (finding, document) in rebuilt
1136                .findings
1137                .iter_mut()
1138                .zip(value["findings"].as_array().unwrap())
1139            {
1140                for (name, evidence) in finding.evidence.iter_mut() {
1141                    if let EvidenceValue::Percentage(_) = evidence {
1142                        *evidence =
1143                            EvidenceValue::Percentage(document["evidence"][name].as_f64().unwrap());
1144                    }
1145                }
1146            }
1147            rebuilt
1148        };
1149        assert_eq!(result, serialized);
1150    }
1151
1152    fn quality_report(future_dates: usize, confidence: MetricConfidence) -> ProfileReport {
1153        let metrics = QualityMetrics {
1154            uniqueness: Some(UniquenessMetrics {
1155                duplicate_rows: 0,
1156                key_uniqueness: 100.0,
1157                high_cardinality_warning: false,
1158                rows_checked: 60_000,
1159                key_column: None,
1160                key_values_checked: None,
1161                duplicate_rows_approximate: false,
1162            }),
1163            timeliness: Some(TimelinessMetrics {
1164                future_dates_count: future_dates,
1165                stale_data_ratio: 0.0,
1166                temporal_violations: 0,
1167                invalid_date_values: 0,
1168                date_values_checked: 10_000,
1169                temporal_pairs_checked: 10_000,
1170            }),
1171            ..QualityMetrics::default()
1172        };
1173        ProfileReport::new(
1174            source(),
1175            vec![],
1176            ExecutionMetadata::new(60_000, 0, 10),
1177            Some(QualityAssessment::new(metrics, confidence)),
1178        )
1179    }
1180
1181    fn skipped(result: &FindingsResult, code: FindingCode) -> Option<&NotEvaluatedReason> {
1182        result
1183            .not_evaluated
1184            .iter()
1185            .find(|entry| entry.code == code)
1186            .map(|entry| &entry.reason)
1187    }
1188
1189    #[test]
1190    fn a_zero_count_from_the_quality_sample_rules_nothing_out() {
1191        // The ordinary large-file shape: every row scanned, timeliness computed
1192        // over the retained reservoir, duplicates over the full stream.
1193        let sampled = || MetricConfidence::Mixed {
1194            exact_dimensions: vec!["duplicate_rows".to_string()],
1195            sampled_dimensions: vec!["timeliness".to_string()],
1196            sample_size: 10_000,
1197        };
1198
1199        let clean = quality_report(0, sampled()).findings();
1200        assert_eq!(
1201            skipped(&clean, FindingCode::FutureDates),
1202            Some(&NotEvaluatedReason::Sampled)
1203        );
1204        assert_eq!(
1205            skipped(&clean, FindingCode::TemporalOrderViolations),
1206            Some(&NotEvaluatedReason::Sampled)
1207        );
1208        // Counted over every row, so its zero is a clean result.
1209        assert_eq!(skipped(&clean, FindingCode::DuplicateRows), None);
1210
1211        // A witnessed future date stays witnessed, sample or not.
1212        let witnessed = quality_report(3, sampled()).findings();
1213        assert!(codes(&witnessed).contains(&(FindingCode::FutureDates, None)));
1214        assert_eq!(skipped(&witnessed, FindingCode::FutureDates), None);
1215    }
1216
1217    #[test]
1218    fn unrecorded_coverage_does_not_read_as_clean() {
1219        let result = quality_report(0, MetricConfidence::Unrecorded).findings();
1220        for code in [
1221            FindingCode::DuplicateRows,
1222            FindingCode::FutureDates,
1223            FindingCode::TemporalOrderViolations,
1224        ] {
1225            assert_eq!(
1226                skipped(&result, code),
1227                Some(&NotEvaluatedReason::Unrecorded),
1228                "{code}"
1229            );
1230        }
1231    }
1232
1233    #[test]
1234    fn a_report_from_before_ragged_counting_does_not_read_as_clean() {
1235        let mut legacy = report(vec![], ExecutionMetadata::new(5, 0, 10));
1236        legacy.schema_version = 0;
1237        assert_eq!(
1238            skipped(&legacy.findings(), FindingCode::RaggedRows),
1239            Some(&NotEvaluatedReason::Unrecorded)
1240        );
1241
1242        let current = report(vec![], ExecutionMetadata::new(5, 0, 10)).findings();
1243        assert_eq!(skipped(&current, FindingCode::RaggedRows), None);
1244    }
1245
1246    /// A classified column of `numeric` numbers and `text` text values,
1247    /// `locale` of them locale-formatted numbers.
1248    fn classified(name: &str, numeric: usize, text: usize, locale: Option<usize>) -> ColumnProfile {
1249        let mut profile = column(name, numeric + text, 0);
1250        profile.type_homogeneity = Some(TypeHomogeneity {
1251            numeric,
1252            date: 0,
1253            boolean: 0,
1254            text,
1255        });
1256        profile.locale_number_count = locale;
1257        profile
1258    }
1259
1260    fn locale_number_columns(result: &FindingsResult) -> Vec<&str> {
1261        result
1262            .findings
1263            .iter()
1264            .filter(|finding| finding.code == FindingCode::LocaleNumbers)
1265            .filter_map(|finding| finding.column.as_deref())
1266            .collect()
1267    }
1268
1269    #[test]
1270    fn locale_numbers_are_reported_from_half_of_the_text_values() {
1271        let result = report(
1272            vec![
1273                classified("half", 0, 4, Some(2)),
1274                classified("under_half", 0, 5, Some(2)),
1275                // A float column whose decimal-comma values were invalid.
1276                classified("float_with_commas", 9, 1, Some(1)),
1277                classified("clean", 0, 4, Some(0)),
1278            ],
1279            ExecutionMetadata::new(10, 0, 10),
1280        )
1281        .findings();
1282
1283        assert_eq!(
1284            locale_number_columns(&result),
1285            ["half", "float_with_commas"]
1286        );
1287        let half = &result.findings[0];
1288        assert_eq!(half.code, FindingCode::LocaleNumbers);
1289        assert_eq!(
1290            half.evidence,
1291            BTreeMap::from([
1292                ("classified_count".to_string(), EvidenceValue::Count(4)),
1293                ("locale_number_count".to_string(), EvidenceValue::Count(2)),
1294                ("non_null_count".to_string(), EvidenceValue::Count(4)),
1295                ("text_count".to_string(), EvidenceValue::Count(4)),
1296            ])
1297        );
1298        assert_eq!(skipped(&result, FindingCode::LocaleNumbers), None);
1299    }
1300
1301    #[test]
1302    fn an_identifier_column_is_exempt_from_locale_numbers() {
1303        let mut codes = classified("codes", 0, 4, Some(4));
1304        codes.data_type = DataType::Identifier;
1305
1306        let result = report(vec![codes], ExecutionMetadata::new(4, 0, 10)).findings();
1307
1308        assert!(locale_number_columns(&result).is_empty());
1309        assert_eq!(skipped(&result, FindingCode::LocaleNumbers), None);
1310    }
1311
1312    #[test]
1313    fn a_report_from_before_locale_number_counting_does_not_read_as_clean() {
1314        // Classified by a release that did not count locale numbers yet.
1315        let result = report(
1316            vec![classified("legacy", 0, 4, None)],
1317            ExecutionMetadata::new(4, 0, 10),
1318        )
1319        .findings();
1320        assert_eq!(
1321            skipped(&result, FindingCode::LocaleNumbers),
1322            Some(&NotEvaluatedReason::Unrecorded)
1323        );
1324
1325        // Not classified at all: nothing was computed to count from.
1326        let result = report(
1327            vec![column("unclassified", 4, 0)],
1328            ExecutionMetadata::new(4, 0, 10),
1329        )
1330        .findings();
1331        assert_eq!(
1332            skipped(&result, FindingCode::LocaleNumbers),
1333            Some(&NotEvaluatedReason::NotComputed)
1334        );
1335    }
1336
1337    #[test]
1338    fn thresholds_apply_at_the_precision_they_are_reported_at() {
1339        // 20.004 is applied as 20.00, so a 20.00% column is reported and the
1340        // evidence names the threshold that was compared.
1341        let result = FindingPolicy::new()
1342            .null_heavy_percentage(20.004)
1343            .evaluate(&report(
1344                vec![column("fifth", 5, 1)],
1345                ExecutionMetadata::new(5, 1, 10),
1346            ))
1347            .expect("in range");
1348        let finding = result
1349            .findings
1350            .iter()
1351            .find(|finding| finding.code == FindingCode::NullHeavy)
1352            .expect("reported at the applied threshold");
1353        assert_eq!(
1354            finding.evidence["threshold"],
1355            EvidenceValue::Percentage(20.0)
1356        );
1357
1358        // A threshold that rounds to zero would report every column.
1359        for value in [1e-9, 0.004] {
1360            assert!(
1361                FindingPolicy::new()
1362                    .null_heavy_percentage(value)
1363                    .validate()
1364                    .is_err(),
1365                "{value} was accepted"
1366            );
1367        }
1368        assert!(
1369            FindingPolicy::new()
1370                .null_heavy_percentage(0.005)
1371                .validate()
1372                .is_ok()
1373        );
1374    }
1375
1376    #[test]
1377    fn a_nested_column_is_assessed_by_its_counts_only() {
1378        // Containers carry counts and nothing else (#637); the value-level
1379        // rules say they did not look rather than reporting a clean column.
1380        let mut nested = column("payload", 4, 4);
1381        nested.data_type = DataType::Nested;
1382        let result = report(vec![nested], ExecutionMetadata::new(4, 1, 10)).findings();
1383
1384        assert_eq!(
1385            codes(&result),
1386            vec![(FindingCode::AllNull, Some("payload"))]
1387        );
1388        for code in [
1389            FindingCode::ConstantColumn,
1390            FindingCode::MixedTypes,
1391            FindingCode::SensitivePattern,
1392        ] {
1393            assert!(
1394                result.not_evaluated.contains(&UnevaluatedRule {
1395                    code,
1396                    reason: NotEvaluatedReason::NotComputed,
1397                    columns: vec!["payload".to_string()],
1398                }),
1399                "{code} was not listed: {:?}",
1400                result.not_evaluated
1401            );
1402        }
1403    }
1404
1405    #[test]
1406    fn a_sampled_scan_names_its_reason_and_truncation_wins() {
1407        let sampled = report(
1408            vec![],
1409            ExecutionMetadata::new(100, 0, 10).with_sampling(0.1),
1410        );
1411        let both = report(
1412            vec![],
1413            ExecutionMetadata::new(100, 0, 10)
1414                .with_sampling(0.1)
1415                .with_truncation(TruncationReason::MaxRows(100)),
1416        );
1417
1418        for (report, expected) in [(sampled, "sampled"), (both, "truncated")] {
1419            let finding = report
1420                .findings()
1421                .findings
1422                .into_iter()
1423                .find(|finding| finding.code == FindingCode::PartialScan)
1424                .expect("a partial scan is disclosed");
1425            assert_eq!(
1426                finding.evidence["reason"],
1427                EvidenceValue::Text(expected.to_string())
1428            );
1429        }
1430    }
1431
1432    #[test]
1433    fn warnings_sort_before_info_then_by_code_then_column_position() {
1434        let mut constant = column("z_constant", 5, 0);
1435        constant.unique_count = Some(1);
1436        let mut mixed = column("a_mixed", 5, 0);
1437        mixed.type_homogeneity = Some(TypeHomogeneity {
1438            numeric: 3,
1439            date: 0,
1440            boolean: 0,
1441            text: 2,
1442        });
1443        let result = report(
1444            vec![
1445                constant,
1446                column("sparse", 5, 4),
1447                mixed,
1448                column("empty", 5, 5),
1449            ],
1450            ExecutionMetadata::new(5, 4, 10).with_ragged_row_count(1),
1451        )
1452        .findings();
1453
1454        assert_eq!(
1455            codes(&result),
1456            vec![
1457                (FindingCode::AllNull, Some("empty")),
1458                (FindingCode::MixedTypes, Some("a_mixed")),
1459                (FindingCode::NullHeavy, Some("sparse")),
1460                (FindingCode::RaggedRows, None),
1461                (FindingCode::ConstantColumn, Some("z_constant")),
1462            ]
1463        );
1464        assert!(
1465            result
1466                .findings
1467                .iter()
1468                .all(|finding| finding.severity == finding.code.severity())
1469        );
1470    }
1471}