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