Skip to main content

dataprof_runtime/
profile_builder.rs

1//! Shared conversion from [`StreamingColumnCollection`] / [`StreamingStatistics`]
2//! into [`ColumnProfile`] and quality-check sample maps.
3//!
4//! All engines that need to produce a [`ColumnProfile`] should call
5//! [`build_column_profile`] instead of constructing one manually.
6//! This ensures consistent stats calculation and pattern detection.
7
8use std::collections::HashMap;
9
10use dataprof_core::{
11    BooleanStats, ColumnProfile, ColumnStats, DataType, DateTimeStats, Locale, SemanticHints,
12    TextStats,
13};
14use dataprof_metrics::{
15    analysis::inference::{
16        classify_lexical_forms, count_locale_numbers, is_integer_token, is_null_like_token,
17        parse_strict_boolean_token,
18    },
19    analysis::patterns::looks_like_date,
20    calculate_datetime_stats, calculate_text_stats, detect_patterns,
21    stats::numeric::{calculate_coefficient_of_variation, compute_numeric_stats_with_parsed_count},
22};
23
24use crate::streaming_stats::{StreamingColumnCollection, StreamingStatistics};
25
26/// Inputs that every engine can provide for centralized profile construction.
27pub struct ColumnProfileInput<'a> {
28    pub name: String,
29    pub data_type: DataType,
30    pub total_count: usize,
31    pub null_count: usize,
32    pub unique_count: Option<usize>,
33    /// Whether `unique_count` is an approximate (HLL) estimate. `None` mirrors
34    /// `unique_count: None`; `Some(false)` for exact counts; `Some(true)` once
35    /// the engine's cardinality estimator has spilled to its HLL sketch.
36    pub unique_count_is_approximate: Option<bool>,
37    /// See [`ColumnProfile::unique_count_lower_bound`]: the estimator's
38    /// [`distinct_lower_bound`](dataprof_metrics::CardinalityEstimator::distinct_lower_bound),
39    /// or `None` when the caller has no estimator behind the count.
40    pub unique_count_lower_bound: Option<usize>,
41    pub sample_values: &'a [String],
42    /// Pre-computed text lengths for engines that track them incrementally.
43    /// When `Some`, text stats are built from these instead of re-scanning samples.
44    pub text_lengths: Option<TextLengths>,
45    /// Pre-computed boolean counts (true_count, false_count) for boolean columns.
46    pub boolean_counts: Option<(usize, usize)>,
47    /// When true, skip statistics computation (produce `ColumnStats::None`).
48    pub skip_statistics: bool,
49    /// When true, skip pattern detection (produce `patterns: None`).
50    pub skip_patterns: bool,
51    /// Optional locale for pattern detection (e.g. "IT", "US").
52    pub locale: Option<Locale>,
53    /// Exact aggregates over every numeric value the engine streamed.
54    ///
55    /// When present, these override the sample-derived `min`, `max`, `mean`,
56    /// `std_dev`, and `variance` on numeric columns, so those fields stay
57    /// exact even when `sample_values` no longer covers the full stream.
58    /// `None` means `sample_values` *is* the full data (in-memory sources)
59    /// or the engine has no exact accumulators for the column.
60    pub exact_numeric: Option<ExactNumericAggregates>,
61    /// Number of values over the full stream accepted by the temporal parser.
62    /// `None` means `sample_values` is the complete in-memory column.
63    pub exact_date_matches: Option<usize>,
64}
65
66/// Exact streaming aggregates for a numeric column: O(1)-memory statistics that an
67/// engine computed over the entire stream, as opposed to the bounded
68/// `sample_values` it retained.
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub struct ExactNumericAggregates {
71    pub min: f64,
72    pub max: f64,
73    pub mean: f64,
74    /// Standard deviation with the unbiased (n-1) denominator.
75    pub std_dev: f64,
76    /// Variance with the unbiased (n-1) denominator.
77    pub variance: f64,
78    /// Number of parsed numeric values covered by these aggregates.
79    pub count: usize,
80}
81
82/// Pre-computed text length stats from streaming/columnar engines.
83pub struct TextLengths {
84    pub min_length: usize,
85    pub max_length: usize,
86    pub avg_length: f64,
87}
88
89/// Build a [`ColumnProfile`] from engine-agnostic inputs.
90///
91/// This is the single canonical construction path. Engines provide
92/// pre-inferred `DataType`, counters, sample values, and optionally
93/// pre-computed text lengths; this function handles stats calculation
94/// and pattern detection.
95pub fn build_column_profile(input: ColumnProfileInput<'_>) -> ColumnProfile {
96    if input.data_type == DataType::Nested {
97        return nested_column_profile(input.name, input.total_count, input.null_count);
98    }
99
100    let mut invalid_count = None;
101    let stats = if input.skip_statistics {
102        ColumnStats::None
103    } else {
104        match input.data_type {
105            DataType::Integer | DataType::Float => {
106                let (mut numeric, sampled_numeric) =
107                    compute_numeric_stats_with_parsed_count(input.sample_values);
108                // Values the statistics actually cover. With exact stream
109                // aggregates that is the engine's full parsed count; without
110                // them the sample *is* the full data (in-memory sources).
111                let parsed_total = match &input.exact_numeric {
112                    Some(exact) => exact.count,
113                    None => sampled_numeric,
114                };
115                invalid_count = Some(
116                    input
117                        .total_count
118                        .saturating_sub(input.null_count)
119                        .saturating_sub(parsed_total),
120                );
121                if let Some(exact) = &input.exact_numeric {
122                    numeric.min = exact.min;
123                    numeric.max = exact.max;
124                    numeric.mean = exact.mean;
125                    numeric.std_dev = exact.std_dev.is_finite().then_some(exact.std_dev);
126                    numeric.variance = exact.variance.is_finite().then_some(exact.variance);
127                    numeric.coefficient_of_variation = numeric
128                        .std_dev
129                        .and_then(|value| calculate_coefficient_of_variation(value, exact.mean));
130                    if numeric.std_dev.is_none() {
131                        numeric.skewness = None;
132                        numeric.kurtosis = None;
133                    }
134                    // Order statistics (median, quartiles, mode, skewness,
135                    // kurtosis, outliers) still come from the retained sample;
136                    // disclose that whenever the sample no longer covers every
137                    // numeric value the exact aggregates saw.
138                    if exact.count > sampled_numeric {
139                        numeric.is_approximate = Some(true);
140                    }
141                }
142                if parsed_total == 0 {
143                    ColumnStats::None
144                } else {
145                    ColumnStats::Numeric(numeric)
146                }
147            }
148            DataType::Date => {
149                let parsed_dates = input.exact_date_matches.unwrap_or_else(|| {
150                    input
151                        .sample_values
152                        .iter()
153                        .filter(|value| {
154                            dataprof_metrics::value_matches_hint(
155                                value,
156                                dataprof_core::SemanticHintKind::Temporal,
157                            )
158                        })
159                        .count()
160                });
161                invalid_count = Some(
162                    input
163                        .total_count
164                        .saturating_sub(input.null_count)
165                        .saturating_sub(parsed_dates),
166                );
167                if !input.sample_values.is_empty() {
168                    calculate_datetime_stats(input.sample_values)
169                } else if let Some(tl) = &input.text_lengths {
170                    ColumnStats::Text(TextStats::from_lengths(
171                        tl.min_length,
172                        tl.max_length,
173                        tl.avg_length,
174                    ))
175                } else {
176                    ColumnStats::DateTime(DateTimeStats::empty())
177                }
178            }
179            DataType::Boolean => {
180                let (true_count, false_count) = input.boolean_counts.unwrap_or_else(|| {
181                    let tc = input
182                        .sample_values
183                        .iter()
184                        .filter(|v| parse_strict_boolean_token(v.trim()) == Some(true))
185                        .count();
186                    let fc = input
187                        .sample_values
188                        .iter()
189                        .filter(|v| parse_strict_boolean_token(v.trim()) == Some(false))
190                        .count();
191                    (tc, fc)
192                });
193                let total = true_count + false_count;
194                if total > 0 {
195                    ColumnStats::Boolean(BooleanStats {
196                        true_count,
197                        false_count,
198                        true_ratio: true_count as f64 / total as f64,
199                    })
200                } else {
201                    ColumnStats::None
202                }
203            }
204            DataType::String | DataType::Identifier => {
205                if let Some(tl) = &input.text_lengths {
206                    ColumnStats::Text(TextStats::from_lengths(
207                        tl.min_length,
208                        tl.max_length,
209                        tl.avg_length,
210                    ))
211                } else {
212                    calculate_text_stats(input.sample_values)
213                }
214            }
215            DataType::Nested => unreachable!("nested columns return above"),
216        }
217    };
218
219    // A column with no rows has counts, but no statistics: every aggregate over
220    // zero values is undefined, and the repo's convention for undefined is
221    // absent, not zero. Text and date columns already landed on
222    // `ColumnStats::None` here through their empty-sample arms; numeric and
223    // boolean did not, and reported a min, max, mean, std_dev and variance of
224    // 0.0, and a true_ratio of 0.0 -- plausible numbers describing rows that do
225    // not exist, which is the failure class AGENTS.md singles out. 0.0 is an
226    // ordinary value for a real numeric column, so nothing about that output
227    // looked wrong.
228    //
229    // Numeric and boolean columns also suppress statistics above when rows
230    // exist but no values parsed. This guard covers zero rows for every type.
231    //
232    // The counts above are untouched. "0 of 0 values were invalid" is a fact
233    // about a column that was analyzed, not a statistic over nothing.
234    let stats = if input.total_count == 0 {
235        ColumnStats::None
236    } else {
237        stats
238    };
239
240    let patterns = if input.skip_patterns {
241        None
242    } else {
243        Some(detect_patterns(input.sample_values, input.locale))
244    };
245
246    ColumnProfile {
247        name: input.name,
248        data_type: input.data_type,
249        null_count: input.null_count,
250        total_count: input.total_count,
251        unique_count: input.unique_count,
252        unique_count_is_approximate: input.unique_count_is_approximate,
253        unique_count_lower_bound: input.unique_count_lower_bound,
254        invalid_count,
255        // Classified over the retained sample, so the counts are bounded by the
256        // engine's reservoir on a large source. `classified_count()` against
257        // `total_count - null_count` is what tells a reader which happened.
258        // Not gated by `skip_statistics`: which forms a column holds is
259        // schema-level evidence like the inferred type, not a statistic.
260        type_homogeneity: Some(classify_lexical_forms(input.sample_values)),
261        // Over the same retained sample, so it stays a subset of `text` above.
262        locale_number_count: Some(count_locale_numbers(input.sample_values)),
263        stats,
264        patterns,
265    }
266}
267
268/// Build the profile of a container column: structs, lists and maps.
269///
270/// It reports its counts and nothing else. Its lengths, distinct values,
271/// patterns and lexical forms would all be measured on a serialisation, and
272/// every input path serialises containers differently (#637). Absent is "not
273/// analyzed", which is what they are.
274pub fn nested_column_profile(name: String, total_count: usize, null_count: usize) -> ColumnProfile {
275    ColumnProfile {
276        name,
277        data_type: DataType::Nested,
278        null_count,
279        total_count,
280        unique_count: None,
281        unique_count_is_approximate: None,
282        unique_count_lower_bound: None,
283        invalid_count: None,
284        type_homogeneity: None,
285        locale_number_count: None,
286        stats: ColumnStats::None,
287        patterns: None,
288    }
289}
290
291/// Report every column whose non-null values were all containers as
292/// [`DataType::Nested`], counts only (#637).
293///
294/// For text-based inputs that know which of their values were containers, as
295/// the JSON readers do. `container_counts` holds, per column name, how many of
296/// the analyzed values were an object or an array.
297///
298/// This matches what a typed source reports for the same records: Parquet and
299/// Arrow carry a struct or list type, and profile such a column the same way.
300/// A column that mixes containers with scalars has no typed twin, and stays
301/// the text column its values make it.
302///
303/// The comparison runs against the profile's own counts, so a container is
304/// weighed against exactly the values the null rule left standing. A container
305/// always renders with a bracket, which the null rule never matches.
306pub fn mark_container_columns(
307    profiles: Vec<ColumnProfile>,
308    container_counts: &HashMap<String, usize>,
309) -> Vec<ColumnProfile> {
310    profiles
311        .into_iter()
312        .map(|profile| {
313            let containers = container_counts.get(&profile.name).copied().unwrap_or(0);
314            let non_null = profile.total_count - profile.null_count;
315            if containers > 0 && containers == non_null {
316                nested_column_profile(profile.name, profile.total_count, profile.null_count)
317            } else {
318                profile
319            }
320        })
321        .collect()
322}
323
324/// Convert all columns in a [`StreamingColumnCollection`] into [`ColumnProfile`]s.
325pub fn profiles_from_streaming(
326    column_stats: &StreamingColumnCollection,
327    skip_statistics: bool,
328    skip_patterns: bool,
329    locale: Option<Locale>,
330) -> Vec<ColumnProfile> {
331    profiles_from_streaming_with_hints(
332        column_stats,
333        skip_statistics,
334        skip_patterns,
335        locale,
336        &SemanticHints::default(),
337    )
338}
339
340/// Convert all columns into [`ColumnProfile`]s while applying semantic hints.
341pub fn profiles_from_streaming_with_hints(
342    column_stats: &StreamingColumnCollection,
343    skip_statistics: bool,
344    skip_patterns: bool,
345    locale: Option<Locale>,
346    semantic_hints: &SemanticHints,
347) -> Vec<ColumnProfile> {
348    let mut profiles = Vec::new();
349
350    for column_name in column_stats.column_names() {
351        if let Some(stats) = column_stats.get_column_stats(&column_name) {
352            let profile = profile_from_stats_with_hints(
353                &column_name,
354                stats,
355                skip_statistics,
356                skip_patterns,
357                locale,
358                semantic_hints,
359            );
360            profiles.push(profile);
361        }
362    }
363
364    profiles
365}
366
367/// Convert a single column's [`StreamingStatistics`] into a [`ColumnProfile`].
368pub fn profile_from_stats(
369    name: &str,
370    stats: &StreamingStatistics,
371    skip_statistics: bool,
372    skip_patterns: bool,
373    locale: Option<Locale>,
374) -> ColumnProfile {
375    profile_from_stats_with_hints(
376        name,
377        stats,
378        skip_statistics,
379        skip_patterns,
380        locale,
381        &SemanticHints::default(),
382    )
383}
384
385/// Convert a single column into a [`ColumnProfile`] while applying semantic hints.
386pub fn profile_from_stats_with_hints(
387    name: &str,
388    stats: &StreamingStatistics,
389    skip_statistics: bool,
390    skip_patterns: bool,
391    locale: Option<Locale>,
392    semantic_hints: &SemanticHints,
393) -> ColumnProfile {
394    let data_type = if semantic_hints.is_identifier_column(name) {
395        DataType::Identifier
396    } else {
397        infer_data_type_streaming(stats)
398    };
399    let text_stats = stats.text_length_stats();
400
401    build_column_profile(ColumnProfileInput {
402        name: name.to_string(),
403        data_type,
404        total_count: stats.count,
405        null_count: stats.null_count,
406        unique_count: Some(stats.unique_count()),
407        unique_count_is_approximate: Some(stats.unique_count_is_approximate()),
408        unique_count_lower_bound: Some(stats.unique_count_lower_bound()),
409        sample_values: stats.sample_values(),
410        text_lengths: Some(TextLengths {
411            min_length: text_stats.min_length,
412            max_length: text_stats.max_length,
413            avg_length: text_stats.avg_length,
414        }),
415        boolean_counts: None,
416        skip_statistics,
417        skip_patterns,
418        locale,
419        exact_numeric: stats.exact_numeric_aggregates(),
420        exact_date_matches: Some(stats.date_match_count()),
421    })
422}
423
424/// Infer [`DataType`] from [`StreamingStatistics`] sample values.
425pub fn infer_data_type_streaming(stats: &StreamingStatistics) -> DataType {
426    let sample_values = stats.sample_values();
427    let non_empty: Vec<&String> = sample_values
428        .iter()
429        .filter(|s| !is_null_like_token(s.trim()))
430        .collect();
431
432    if !non_empty.is_empty() {
433        let all_integers = non_empty.iter().all(|s| is_integer_token(s.trim()));
434        if all_integers {
435            return DataType::Integer;
436        }
437
438        // Numeric lexical forms remain numeric even if none are finite enough
439        // to enter the streaming aggregates. `build_column_profile` reports
440        // those unusable values through `invalid_count`.
441        let numeric_count = non_empty
442            .iter()
443            .filter(|s| s.trim().parse::<f64>().is_ok())
444            .count();
445        if numeric_count as f64 / non_empty.len() as f64 > 0.8 {
446            return DataType::Float;
447        }
448
449        // `.trim()`, like every sibling predicate in this function and like
450        // `infer_type`. Without it a value that arrived padded — CSV parsing
451        // does not trim by default — was a date to the buffered engines and
452        // text to this one, which is the same cross-engine split the shared
453        // grammar exists to close.
454        let date_like_count = non_empty
455            .iter()
456            .take(100)
457            .filter(|s| looks_like_date(s.trim()))
458            .count();
459
460        if date_like_count as f64 / non_empty.len().min(100) as f64 > 0.7 {
461            return DataType::Date;
462        }
463
464        let bool_count = non_empty
465            .iter()
466            .filter(|s| parse_strict_boolean_token(s.trim()).is_some())
467            .count();
468
469        if bool_count as f64 / non_empty.len() as f64 >= 0.9 {
470            return DataType::Boolean;
471        }
472    }
473
474    DataType::String
475}
476
477/// Build a sample `HashMap` from a [`StreamingColumnCollection`] suitable for
478/// `QualityMetrics::calculate_from_data()`.
479pub fn quality_check_samples(
480    column_stats: &StreamingColumnCollection,
481) -> HashMap<String, Vec<String>> {
482    let mut samples = HashMap::new();
483
484    for column_name in column_stats.column_names() {
485        if let Some(stats) = column_stats.get_column_stats(&column_name) {
486            let sample_values: Vec<String> = stats.sample_values().to_vec();
487            samples.insert(column_name, sample_values);
488        }
489    }
490
491    samples
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::streaming_stats::StreamingColumnCollection;
498
499    #[test]
500    fn test_profiles_from_streaming() {
501        let mut collection = StreamingColumnCollection::new();
502        let headers = vec!["name".to_string(), "age".to_string()];
503
504        collection.process_record(&headers, vec!["Alice".to_string(), "30".to_string()]);
505        collection.process_record(&headers, vec!["Bob".to_string(), "25".to_string()]);
506        collection.process_record(&headers, vec!["Charlie".to_string(), "35".to_string()]);
507
508        let profiles = profiles_from_streaming(&collection, false, false, None);
509        assert_eq!(profiles.len(), 2);
510
511        let age = profiles.iter().find(|p| p.name == "age").unwrap();
512        assert_eq!(age.data_type, DataType::Integer);
513        assert_eq!(age.total_count, 3);
514    }
515
516    #[test]
517    fn test_all_non_finite_tokens_keep_streaming_float_type() {
518        let mut collection = StreamingColumnCollection::new();
519        let headers = vec!["value".to_string()];
520
521        collection.process_record(&headers, vec!["Infinity".to_string()]);
522        collection.process_record(&headers, vec!["-inf".to_string()]);
523
524        let profiles = profiles_from_streaming(&collection, false, false, None);
525        let value = profiles
526            .iter()
527            .find(|profile| profile.name == "value")
528            .expect("value profile");
529
530        assert_eq!(value.data_type, DataType::Float);
531        assert_eq!(value.invalid_count, Some(2));
532        assert!(matches!(value.stats, ColumnStats::None));
533    }
534
535    #[test]
536    fn type_homogeneity_is_classified_from_the_retained_sample() {
537        // The streaming path only holds its reservoir, so the counts describe
538        // the sample. `classified_count()` short of the non-null total is the
539        // signal that the shares are sampled — nothing else discloses it.
540        let samples = ["1", "2", "junk", ""].map(String::from).to_vec();
541        let profile = build_column_profile(ColumnProfileInput {
542            name: "v".to_string(),
543            data_type: DataType::String,
544            total_count: 4_000,
545            null_count: 1_000,
546            unique_count: Some(3),
547            unique_count_is_approximate: Some(false),
548            unique_count_lower_bound: None,
549            sample_values: &samples,
550            text_lengths: None,
551            boolean_counts: None,
552            // Not gated by the analysis selection: a column's lexical makeup is
553            // schema-level evidence, like its inferred type.
554            skip_statistics: true,
555            skip_patterns: true,
556            exact_numeric: None,
557            exact_date_matches: None,
558            locale: None,
559        });
560
561        let counts = profile
562            .type_homogeneity
563            .expect("classification runs on every column");
564        assert_eq!(counts.numeric, 2);
565        assert_eq!(counts.text, 1);
566        assert_eq!(counts.classified_count(), 3, "null-like values are skipped");
567        assert!(
568            counts.classified_count() < profile.total_count - profile.null_count,
569            "a sample-derived count must read as short of the column"
570        );
571    }
572
573    #[test]
574    fn test_quality_check_samples() {
575        let mut collection = StreamingColumnCollection::new();
576        let headers = vec!["col".to_string()];
577
578        collection.process_record(&headers, vec!["val1".to_string()]);
579        collection.process_record(&headers, vec!["val2".to_string()]);
580
581        let samples = quality_check_samples(&collection);
582        assert!(samples.contains_key("col"));
583        assert_eq!(samples["col"].len(), 2);
584    }
585
586    #[test]
587    fn typed_columns_without_parsed_values_have_no_statistics() {
588        for data_type in [DataType::Integer, DataType::Float, DataType::Boolean] {
589            for values in [["", ""], ["junk", "Infinity"]] {
590                let samples = values.map(String::from);
591                let null_count = if values[0].is_empty() { 2 } else { 0 };
592                let profile = build_column_profile(ColumnProfileInput {
593                    name: "v".to_string(),
594                    data_type: data_type.clone(),
595                    total_count: 2,
596                    null_count,
597                    unique_count: None,
598                    unique_count_is_approximate: None,
599                    unique_count_lower_bound: None,
600                    sample_values: &samples,
601                    text_lengths: None,
602                    boolean_counts: None,
603                    skip_statistics: false,
604                    skip_patterns: false,
605                    locale: None,
606                    exact_numeric: None,
607                    exact_date_matches: None,
608                });
609
610                assert!(matches!(profile.stats, ColumnStats::None));
611                assert_eq!(profile.data_type, data_type);
612                assert_eq!(profile.total_count, 2);
613                assert_eq!(profile.null_count, null_count);
614                if matches!(data_type, DataType::Integer | DataType::Float) {
615                    assert_eq!(profile.invalid_count, Some(2 - null_count));
616                }
617                assert!(profile.patterns.is_some());
618            }
619        }
620    }
621
622    #[test]
623    fn exact_zero_statistics_survive_a_sample_without_parsed_values() {
624        // A bounded sample can miss every usable value. The full-stream
625        // count, not the sample count or the aggregate's value, governs absence.
626        for data_type in [DataType::Integer, DataType::Boolean] {
627            let samples = [String::new()];
628            let profile = build_column_profile(ColumnProfileInput {
629                name: "v".to_string(),
630                data_type: data_type.clone(),
631                total_count: 3,
632                null_count: 1,
633                unique_count: None,
634                unique_count_is_approximate: None,
635                unique_count_lower_bound: None,
636                sample_values: &samples,
637                text_lengths: None,
638                boolean_counts: Some((0, 2)),
639                skip_statistics: false,
640                skip_patterns: true,
641                locale: None,
642                exact_numeric: Some(ExactNumericAggregates {
643                    min: 0.0,
644                    max: 0.0,
645                    mean: 0.0,
646                    std_dev: 0.0,
647                    variance: 0.0,
648                    count: 2,
649                }),
650                exact_date_matches: None,
651            });
652
653            match profile.stats {
654                ColumnStats::Numeric(stats) => {
655                    assert_eq!(stats.min, 0.0);
656                    assert_eq!(stats.mean, 0.0);
657                    assert_eq!(stats.is_approximate, Some(true));
658                    assert_eq!(profile.invalid_count, Some(0));
659                }
660                ColumnStats::Boolean(stats) => {
661                    assert_eq!(stats.true_ratio, 0.0);
662                    assert_eq!(stats.false_count, 2);
663                }
664                other => panic!("expected measured zero statistics, got {other:?}"),
665            }
666        }
667    }
668
669    #[test]
670    fn an_overflowing_exact_spread_clears_the_sample_shape() {
671        // The bounded sample missed the extreme values, so on its own it has a
672        // finite spread; the full stream does not (#784).
673        let samples: Vec<String> = ["5", "0.5", "5", "7", "3"]
674            .iter()
675            .map(|value| value.to_string())
676            .collect();
677        let profile = build_column_profile(ColumnProfileInput {
678            name: "x".to_string(),
679            data_type: DataType::Float,
680            total_count: 20_000,
681            null_count: 0,
682            unique_count: None,
683            unique_count_is_approximate: None,
684            unique_count_lower_bound: None,
685            sample_values: &samples,
686            text_lengths: None,
687            boolean_counts: None,
688            skip_statistics: false,
689            skip_patterns: true,
690            locale: None,
691            exact_numeric: Some(ExactNumericAggregates {
692                min: -1e308,
693                max: 1e308,
694                mean: 2.9167,
695                std_dev: f64::NAN,
696                variance: f64::NAN,
697                count: 20_000,
698            }),
699            exact_date_matches: None,
700        });
701
702        let ColumnStats::Numeric(stats) = profile.stats else {
703            panic!("expected numeric statistics, got {:?}", profile.stats);
704        };
705        assert_eq!(stats.variance, None);
706        assert_eq!(stats.std_dev, None);
707        assert_eq!(stats.coefficient_of_variation, None);
708        assert_eq!(stats.skewness, None);
709        assert_eq!(stats.kurtosis, None);
710        // Order statistics still describe the retained sample.
711        assert_eq!(stats.median, Some(5.0));
712        assert_eq!(stats.is_approximate, Some(true));
713    }
714
715    #[test]
716    fn test_boolean_stats_with_counts() {
717        let samples = vec!["True".to_string(), "False".to_string(), "True".to_string()];
718        let profile = build_column_profile(ColumnProfileInput {
719            name: "flag".to_string(),
720            data_type: DataType::Boolean,
721            total_count: 3,
722            null_count: 0,
723            unique_count: Some(2),
724            unique_count_is_approximate: Some(false),
725            unique_count_lower_bound: None,
726            sample_values: &samples,
727            text_lengths: None,
728            boolean_counts: Some((2, 1)),
729            skip_statistics: false,
730            skip_patterns: false,
731            exact_numeric: None,
732            exact_date_matches: None,
733            locale: None,
734        });
735
736        match &profile.stats {
737            ColumnStats::Boolean(b) => {
738                assert_eq!(b.true_count, 2);
739                assert_eq!(b.false_count, 1);
740                assert!((b.true_ratio - 2.0 / 3.0).abs() < 0.001);
741            }
742            other => panic!("expected Boolean stats, got {:?}", other),
743        }
744    }
745
746    #[test]
747    fn test_boolean_stats_fallback_case_insensitive() {
748        let samples = vec![
749            "true".to_string(),
750            "FALSE".to_string(),
751            " True ".to_string(),
752        ];
753        let profile = build_column_profile(ColumnProfileInput {
754            name: "flag".to_string(),
755            data_type: DataType::Boolean,
756            total_count: 3,
757            null_count: 0,
758            unique_count: Some(2),
759            unique_count_is_approximate: Some(false),
760            unique_count_lower_bound: None,
761            sample_values: &samples,
762            text_lengths: None,
763            boolean_counts: None,
764            skip_statistics: false,
765            skip_patterns: false,
766            exact_numeric: None,
767            exact_date_matches: None,
768            locale: None,
769        });
770
771        match &profile.stats {
772            ColumnStats::Boolean(b) => {
773                assert_eq!(b.true_count, 2);
774                assert_eq!(b.false_count, 1);
775                assert!((b.true_ratio - 2.0 / 3.0).abs() < 0.001);
776            }
777            other => panic!("expected Boolean stats, got {:?}", other),
778        }
779    }
780
781    #[test]
782    fn test_skip_statistics() {
783        let samples = vec!["10".to_string(), "20".to_string(), "30".to_string()];
784        let profile = build_column_profile(ColumnProfileInput {
785            name: "num".to_string(),
786            data_type: DataType::Integer,
787            total_count: 3,
788            null_count: 0,
789            unique_count: Some(3),
790            unique_count_is_approximate: Some(false),
791            unique_count_lower_bound: None,
792            sample_values: &samples,
793            text_lengths: None,
794            boolean_counts: None,
795            skip_statistics: true,
796            skip_patterns: false,
797            exact_numeric: None,
798            exact_date_matches: None,
799            locale: None,
800        });
801
802        assert!(matches!(profile.stats, ColumnStats::None));
803        assert_eq!(profile.data_type, DataType::Integer);
804    }
805
806    #[test]
807    fn test_skip_patterns() {
808        let samples = vec!["hello".to_string(), "world".to_string()];
809        let profile = build_column_profile(ColumnProfileInput {
810            name: "text".to_string(),
811            data_type: DataType::String,
812            total_count: 2,
813            null_count: 0,
814            unique_count: Some(2),
815            unique_count_is_approximate: Some(false),
816            unique_count_lower_bound: None,
817            sample_values: &samples,
818            text_lengths: None,
819            boolean_counts: None,
820            skip_statistics: false,
821            skip_patterns: true,
822            exact_numeric: None,
823            exact_date_matches: None,
824            locale: None,
825        });
826
827        // skip_patterns must be distinguishable from "scanned, nothing matched",
828        // otherwise downstream redaction gates cannot fail closed.
829        assert!(profile.patterns.is_none());
830        assert!(matches!(profile.stats, ColumnStats::Text(_)));
831    }
832
833    #[test]
834    fn test_scanned_without_matches_is_not_none() {
835        // The counterpart to `test_skip_patterns`: a column that *was* scanned
836        // and matched nothing yields `Some([])`, which downstream consumers may
837        // safely read as "no sensitive data here".
838        let samples = vec!["hello".to_string(), "world".to_string()];
839        let profile = build_column_profile(ColumnProfileInput {
840            name: "text".to_string(),
841            data_type: DataType::String,
842            total_count: 2,
843            null_count: 0,
844            unique_count: Some(2),
845            unique_count_is_approximate: Some(false),
846            unique_count_lower_bound: None,
847            sample_values: &samples,
848            text_lengths: None,
849            boolean_counts: None,
850            skip_statistics: false,
851            skip_patterns: false,
852            exact_numeric: None,
853            exact_date_matches: None,
854            locale: None,
855        });
856
857        assert!(profile.patterns.is_some_and(|p| p.is_empty()));
858    }
859
860    #[test]
861    fn test_all_packs_default() {
862        let samples = vec!["42".to_string(), "99".to_string()];
863        let profile = build_column_profile(ColumnProfileInput {
864            name: "val".to_string(),
865            data_type: DataType::Integer,
866            total_count: 2,
867            null_count: 0,
868            unique_count: Some(2),
869            unique_count_is_approximate: Some(false),
870            unique_count_lower_bound: None,
871            sample_values: &samples,
872            text_lengths: None,
873            boolean_counts: None,
874            skip_statistics: false,
875            skip_patterns: false,
876            exact_numeric: None,
877            exact_date_matches: None,
878            locale: None,
879        });
880
881        assert!(matches!(profile.stats, ColumnStats::Numeric(_)));
882        assert_eq!(profile.data_type, DataType::Integer);
883    }
884}