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::column::stats_over_values,
16    analysis::inference::{
17        classify_lexical_forms, count_locale_numbers, holds_digit_codes, is_inferred_date_token,
18        is_integer_token, is_null_like_token, parse_strict_boolean_token,
19    },
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 non-null value has counts, but no statistics: every
220    // aggregate over zero values is undefined, and the repo's convention for
221    // undefined is absent, not zero. Without this guard, a column with no rows
222    // reported a numeric min, max, mean, std_dev and variance of 0.0 and a
223    // true_ratio of 0.0, and a text column of nulls reported lengths of 0
224    // (#983): plausible numbers describing values that do not exist, which is
225    // the failure class AGENTS.md singles out. 0.0 and 0 are ordinary values
226    // for a real column, so nothing about that output looked wrong.
227    //
228    // The counts above are untouched. "0 of 0 values were invalid" is a fact
229    // about a column that was analyzed, not a statistic over nothing.
230    let stats = stats_over_values(stats, input.total_count, input.null_count);
231
232    let patterns = if input.skip_patterns {
233        None
234    } else {
235        Some(detect_patterns(input.sample_values, input.locale))
236    };
237
238    ColumnProfile {
239        name: input.name,
240        data_type: input.data_type,
241        null_count: input.null_count,
242        total_count: input.total_count,
243        unique_count: input.unique_count,
244        unique_count_is_approximate: input.unique_count_is_approximate,
245        unique_count_lower_bound: input.unique_count_lower_bound,
246        invalid_count,
247        // Classified over the retained sample, so the counts are bounded by the
248        // engine's reservoir on a large source. `classified_count()` against
249        // `total_count - null_count` is what tells a reader which happened.
250        // Not gated by `skip_statistics`: which forms a column holds is
251        // schema-level evidence like the inferred type, not a statistic.
252        type_homogeneity: Some(classify_lexical_forms(input.sample_values)),
253        // Over the same retained sample, so it stays a subset of `text` above.
254        locale_number_count: Some(count_locale_numbers(input.sample_values)),
255        stats,
256        patterns,
257    }
258}
259
260/// Build the profile of a container column: structs, lists and maps.
261///
262/// It reports its counts and nothing else. Its lengths, distinct values,
263/// patterns and lexical forms would all be measured on a serialisation, and
264/// every input path serialises containers differently (#637). Absent is "not
265/// analyzed", which is what they are.
266pub fn nested_column_profile(name: String, total_count: usize, null_count: usize) -> ColumnProfile {
267    ColumnProfile {
268        name,
269        data_type: DataType::Nested,
270        null_count,
271        total_count,
272        unique_count: None,
273        unique_count_is_approximate: None,
274        unique_count_lower_bound: None,
275        invalid_count: None,
276        type_homogeneity: None,
277        locale_number_count: None,
278        stats: ColumnStats::None,
279        patterns: None,
280    }
281}
282
283/// Report every column whose non-null values were all containers as
284/// [`DataType::Nested`], counts only (#637).
285///
286/// For text-based inputs that know which of their values were containers, as
287/// the JSON readers do. `container_counts` holds, per column name, how many of
288/// the analyzed values were an object or an array.
289///
290/// This matches what a typed source reports for the same records: Parquet and
291/// Arrow carry a struct or list type, and profile such a column the same way.
292/// A column that mixes containers with scalars has no typed twin, and stays
293/// the text column its values make it.
294///
295/// The comparison runs against the profile's own counts, so a container is
296/// weighed against exactly the values the null rule left standing. A container
297/// always renders with a bracket, which the null rule never matches.
298pub fn mark_container_columns(
299    profiles: Vec<ColumnProfile>,
300    container_counts: &HashMap<String, usize>,
301) -> Vec<ColumnProfile> {
302    profiles
303        .into_iter()
304        .map(|profile| {
305            let containers = container_counts.get(&profile.name).copied().unwrap_or(0);
306            let non_null = profile.total_count - profile.null_count;
307            if containers > 0 && containers == non_null {
308                nested_column_profile(profile.name, profile.total_count, profile.null_count)
309            } else {
310                profile
311            }
312        })
313        .collect()
314}
315
316/// Convert all columns in a [`StreamingColumnCollection`] into [`ColumnProfile`]s.
317pub fn profiles_from_streaming(
318    column_stats: &StreamingColumnCollection,
319    skip_statistics: bool,
320    skip_patterns: bool,
321    locale: Option<Locale>,
322) -> Vec<ColumnProfile> {
323    profiles_from_streaming_with_hints(
324        column_stats,
325        skip_statistics,
326        skip_patterns,
327        locale,
328        &SemanticHints::default(),
329    )
330}
331
332/// Convert all columns into [`ColumnProfile`]s while applying semantic hints.
333pub fn profiles_from_streaming_with_hints(
334    column_stats: &StreamingColumnCollection,
335    skip_statistics: bool,
336    skip_patterns: bool,
337    locale: Option<Locale>,
338    semantic_hints: &SemanticHints,
339) -> Vec<ColumnProfile> {
340    let mut profiles = Vec::new();
341
342    for column_name in column_stats.column_names() {
343        if let Some(stats) = column_stats.get_column_stats(&column_name) {
344            let profile = profile_from_stats_with_hints(
345                &column_name,
346                stats,
347                skip_statistics,
348                skip_patterns,
349                locale,
350                semantic_hints,
351            );
352            profiles.push(profile);
353        }
354    }
355
356    profiles
357}
358
359/// Convert a single column's [`StreamingStatistics`] into a [`ColumnProfile`].
360pub fn profile_from_stats(
361    name: &str,
362    stats: &StreamingStatistics,
363    skip_statistics: bool,
364    skip_patterns: bool,
365    locale: Option<Locale>,
366) -> ColumnProfile {
367    profile_from_stats_with_hints(
368        name,
369        stats,
370        skip_statistics,
371        skip_patterns,
372        locale,
373        &SemanticHints::default(),
374    )
375}
376
377/// Convert a single column into a [`ColumnProfile`] while applying semantic hints.
378pub fn profile_from_stats_with_hints(
379    name: &str,
380    stats: &StreamingStatistics,
381    skip_statistics: bool,
382    skip_patterns: bool,
383    locale: Option<Locale>,
384    semantic_hints: &SemanticHints,
385) -> ColumnProfile {
386    let data_type = if semantic_hints.is_identifier_column(name) {
387        DataType::Identifier
388    } else {
389        infer_data_type_streaming(stats)
390    };
391    let text_stats = stats.text_length_stats();
392
393    build_column_profile(ColumnProfileInput {
394        name: name.to_string(),
395        data_type,
396        total_count: stats.count,
397        null_count: stats.null_count,
398        unique_count: Some(stats.unique_count()),
399        unique_count_is_approximate: Some(stats.unique_count_is_approximate()),
400        unique_count_lower_bound: Some(stats.unique_count_lower_bound()),
401        sample_values: stats.sample_values(),
402        text_lengths: Some(TextLengths {
403            min_length: text_stats.min_length,
404            max_length: text_stats.max_length,
405            avg_length: text_stats.avg_length,
406        }),
407        boolean_counts: None,
408        skip_statistics,
409        skip_patterns,
410        locale,
411        exact_numeric: stats.exact_numeric_aggregates(),
412        exact_date_matches: Some(stats.date_match_count()),
413    })
414}
415
416/// Infer [`DataType`] from [`StreamingStatistics`] sample values.
417pub fn infer_data_type_streaming(stats: &StreamingStatistics) -> DataType {
418    let sample_values = stats.sample_values();
419    let non_empty: Vec<&String> = sample_values
420        .iter()
421        .filter(|s| !is_null_like_token(s.trim()))
422        .collect();
423
424    if !non_empty.is_empty() {
425        // The same question `infer_type` asks, so codes are text on every engine.
426        let digit_codes = holds_digit_codes(non_empty.iter().map(|s| s.trim()));
427
428        let all_integers = non_empty.iter().all(|s| is_integer_token(s.trim()));
429        if !digit_codes && all_integers {
430            return DataType::Integer;
431        }
432
433        // Numeric lexical forms remain numeric even if none are finite enough
434        // to enter the streaming aggregates. `build_column_profile` reports
435        // those unusable values through `invalid_count`.
436        let numeric_count = non_empty
437            .iter()
438            .filter(|s| s.trim().parse::<f64>().is_ok())
439            .count();
440        if !digit_codes && numeric_count as f64 / non_empty.len() as f64 > 0.8 {
441            return DataType::Float;
442        }
443
444        // `.trim()`, like every sibling predicate in this function and like
445        // `infer_type`. Without it a value that arrived padded — CSV parsing
446        // does not trim by default — was a date to the buffered engines and
447        // text to this one, which is the same cross-engine split the shared
448        // grammar exists to close. Over every value, as `infer_type` counts and
449        // as the shares above do: a column whose first 100 values were dates
450        // was a date here and text everywhere else (#915).
451        let date_like_count = non_empty
452            .iter()
453            .filter(|s| is_inferred_date_token(s.trim()))
454            .count();
455
456        if date_like_count as f64 / non_empty.len() as f64 > 0.7 {
457            return DataType::Date;
458        }
459
460        let bool_count = non_empty
461            .iter()
462            .filter(|s| parse_strict_boolean_token(s.trim()).is_some())
463            .count();
464
465        if bool_count as f64 / non_empty.len() as f64 >= 0.9 {
466            return DataType::Boolean;
467        }
468    }
469
470    DataType::String
471}
472
473/// Build a sample `HashMap` from a [`StreamingColumnCollection`] suitable for
474/// `QualityMetrics::calculate_from_data()`.
475pub fn quality_check_samples(
476    column_stats: &StreamingColumnCollection,
477) -> HashMap<String, Vec<String>> {
478    let mut samples = HashMap::new();
479
480    for column_name in column_stats.column_names() {
481        if let Some(stats) = column_stats.get_column_stats(&column_name) {
482            let sample_values: Vec<String> = stats.sample_values().to_vec();
483            samples.insert(column_name, sample_values);
484        }
485    }
486
487    samples
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use crate::streaming_stats::StreamingColumnCollection;
494
495    #[test]
496    fn test_profiles_from_streaming() {
497        let mut collection = StreamingColumnCollection::new();
498        let headers = vec!["name".to_string(), "age".to_string()];
499
500        collection.process_record(&headers, vec!["Alice".to_string(), "30".to_string()]);
501        collection.process_record(&headers, vec!["Bob".to_string(), "25".to_string()]);
502        collection.process_record(&headers, vec!["Charlie".to_string(), "35".to_string()]);
503
504        let profiles = profiles_from_streaming(&collection, false, false, None);
505        assert_eq!(profiles.len(), 2);
506
507        let age = profiles.iter().find(|p| p.name == "age").unwrap();
508        assert_eq!(age.data_type, DataType::Integer);
509        assert_eq!(age.total_count, 3);
510    }
511
512    #[test]
513    fn test_all_non_finite_tokens_keep_streaming_float_type() {
514        let mut collection = StreamingColumnCollection::new();
515        let headers = vec!["value".to_string()];
516
517        collection.process_record(&headers, vec!["Infinity".to_string()]);
518        collection.process_record(&headers, vec!["-inf".to_string()]);
519
520        let profiles = profiles_from_streaming(&collection, false, false, None);
521        let value = profiles
522            .iter()
523            .find(|profile| profile.name == "value")
524            .expect("value profile");
525
526        assert_eq!(value.data_type, DataType::Float);
527        assert_eq!(value.invalid_count, Some(2));
528        assert!(matches!(value.stats, ColumnStats::None));
529    }
530
531    #[test]
532    fn type_homogeneity_is_classified_from_the_retained_sample() {
533        // The streaming path only holds its reservoir, so the counts describe
534        // the sample. `classified_count()` short of the non-null total is the
535        // signal that the shares are sampled — nothing else discloses it.
536        let samples = ["1", "2", "junk", ""].map(String::from).to_vec();
537        let profile = build_column_profile(ColumnProfileInput {
538            name: "v".to_string(),
539            data_type: DataType::String,
540            total_count: 4_000,
541            null_count: 1_000,
542            unique_count: Some(3),
543            unique_count_is_approximate: Some(false),
544            unique_count_lower_bound: None,
545            sample_values: &samples,
546            text_lengths: None,
547            boolean_counts: None,
548            // Not gated by the analysis selection: a column's lexical makeup is
549            // schema-level evidence, like its inferred type.
550            skip_statistics: true,
551            skip_patterns: true,
552            exact_numeric: None,
553            exact_date_matches: None,
554            locale: None,
555        });
556
557        let counts = profile
558            .type_homogeneity
559            .expect("classification runs on every column");
560        assert_eq!(counts.numeric, 2);
561        assert_eq!(counts.text, 1);
562        assert_eq!(counts.classified_count(), 3, "null-like values are skipped");
563        assert!(
564            counts.classified_count() < profile.total_count - profile.null_count,
565            "a sample-derived count must read as short of the column"
566        );
567    }
568
569    #[test]
570    fn test_quality_check_samples() {
571        let mut collection = StreamingColumnCollection::new();
572        let headers = vec!["col".to_string()];
573
574        collection.process_record(&headers, vec!["val1".to_string()]);
575        collection.process_record(&headers, vec!["val2".to_string()]);
576
577        let samples = quality_check_samples(&collection);
578        assert!(samples.contains_key("col"));
579        assert_eq!(samples["col"].len(), 2);
580    }
581
582    #[test]
583    fn typed_columns_without_parsed_values_have_no_statistics() {
584        for data_type in [DataType::Integer, DataType::Float, DataType::Boolean] {
585            for values in [["", ""], ["junk", "Infinity"]] {
586                let samples = values.map(String::from);
587                let null_count = if values[0].is_empty() { 2 } else { 0 };
588                let profile = build_column_profile(ColumnProfileInput {
589                    name: "v".to_string(),
590                    data_type: data_type.clone(),
591                    total_count: 2,
592                    null_count,
593                    unique_count: None,
594                    unique_count_is_approximate: None,
595                    unique_count_lower_bound: None,
596                    sample_values: &samples,
597                    text_lengths: None,
598                    boolean_counts: None,
599                    skip_statistics: false,
600                    skip_patterns: false,
601                    locale: None,
602                    exact_numeric: None,
603                    exact_date_matches: None,
604                });
605
606                assert!(matches!(profile.stats, ColumnStats::None));
607                assert_eq!(profile.data_type, data_type);
608                assert_eq!(profile.total_count, 2);
609                assert_eq!(profile.null_count, null_count);
610                if matches!(data_type, DataType::Integer | DataType::Float) {
611                    assert_eq!(profile.invalid_count, Some(2 - null_count));
612                }
613                assert!(profile.patterns.is_some());
614            }
615        }
616    }
617
618    #[test]
619    fn exact_zero_statistics_survive_a_sample_without_parsed_values() {
620        // A bounded sample can miss every usable value. The full-stream
621        // count, not the sample count or the aggregate's value, governs absence.
622        for data_type in [DataType::Integer, DataType::Boolean] {
623            let samples = [String::new()];
624            let profile = build_column_profile(ColumnProfileInput {
625                name: "v".to_string(),
626                data_type: data_type.clone(),
627                total_count: 3,
628                null_count: 1,
629                unique_count: None,
630                unique_count_is_approximate: None,
631                unique_count_lower_bound: None,
632                sample_values: &samples,
633                text_lengths: None,
634                boolean_counts: Some((0, 2)),
635                skip_statistics: false,
636                skip_patterns: true,
637                locale: None,
638                exact_numeric: Some(ExactNumericAggregates {
639                    min: 0.0,
640                    max: 0.0,
641                    mean: 0.0,
642                    std_dev: 0.0,
643                    variance: 0.0,
644                    count: 2,
645                }),
646                exact_date_matches: None,
647            });
648
649            match profile.stats {
650                ColumnStats::Numeric(stats) => {
651                    assert_eq!(stats.min, 0.0);
652                    assert_eq!(stats.mean, 0.0);
653                    assert_eq!(stats.is_approximate, Some(true));
654                    assert_eq!(profile.invalid_count, Some(0));
655                }
656                ColumnStats::Boolean(stats) => {
657                    assert_eq!(stats.true_ratio, 0.0);
658                    assert_eq!(stats.false_count, 2);
659                }
660                other => panic!("expected measured zero statistics, got {other:?}"),
661            }
662        }
663    }
664
665    #[test]
666    fn an_overflowing_exact_spread_clears_the_sample_shape() {
667        // The bounded sample missed the extreme values, so on its own it has a
668        // finite spread; the full stream does not (#784).
669        let samples: Vec<String> = ["5", "0.5", "5", "7", "3"]
670            .iter()
671            .map(|value| value.to_string())
672            .collect();
673        let profile = build_column_profile(ColumnProfileInput {
674            name: "x".to_string(),
675            data_type: DataType::Float,
676            total_count: 20_000,
677            null_count: 0,
678            unique_count: None,
679            unique_count_is_approximate: None,
680            unique_count_lower_bound: None,
681            sample_values: &samples,
682            text_lengths: None,
683            boolean_counts: None,
684            skip_statistics: false,
685            skip_patterns: true,
686            locale: None,
687            exact_numeric: Some(ExactNumericAggregates {
688                min: -1e308,
689                max: 1e308,
690                mean: 2.9167,
691                std_dev: f64::NAN,
692                variance: f64::NAN,
693                count: 20_000,
694            }),
695            exact_date_matches: None,
696        });
697
698        let ColumnStats::Numeric(stats) = profile.stats else {
699            panic!("expected numeric statistics, got {:?}", profile.stats);
700        };
701        assert_eq!(stats.variance, None);
702        assert_eq!(stats.std_dev, None);
703        assert_eq!(stats.coefficient_of_variation, None);
704        assert_eq!(stats.skewness, None);
705        assert_eq!(stats.kurtosis, None);
706        // Order statistics still describe the retained sample.
707        assert_eq!(stats.median, Some(5.0));
708        assert_eq!(stats.is_approximate, Some(true));
709    }
710
711    #[test]
712    fn test_boolean_stats_with_counts() {
713        let samples = vec!["True".to_string(), "False".to_string(), "True".to_string()];
714        let profile = build_column_profile(ColumnProfileInput {
715            name: "flag".to_string(),
716            data_type: DataType::Boolean,
717            total_count: 3,
718            null_count: 0,
719            unique_count: Some(2),
720            unique_count_is_approximate: Some(false),
721            unique_count_lower_bound: None,
722            sample_values: &samples,
723            text_lengths: None,
724            boolean_counts: Some((2, 1)),
725            skip_statistics: false,
726            skip_patterns: false,
727            exact_numeric: None,
728            exact_date_matches: None,
729            locale: None,
730        });
731
732        match &profile.stats {
733            ColumnStats::Boolean(b) => {
734                assert_eq!(b.true_count, 2);
735                assert_eq!(b.false_count, 1);
736                assert!((b.true_ratio - 2.0 / 3.0).abs() < 0.001);
737            }
738            other => panic!("expected Boolean stats, got {:?}", other),
739        }
740    }
741
742    #[test]
743    fn test_boolean_stats_fallback_case_insensitive() {
744        let samples = vec![
745            "true".to_string(),
746            "FALSE".to_string(),
747            " True ".to_string(),
748        ];
749        let profile = build_column_profile(ColumnProfileInput {
750            name: "flag".to_string(),
751            data_type: DataType::Boolean,
752            total_count: 3,
753            null_count: 0,
754            unique_count: Some(2),
755            unique_count_is_approximate: Some(false),
756            unique_count_lower_bound: None,
757            sample_values: &samples,
758            text_lengths: None,
759            boolean_counts: None,
760            skip_statistics: false,
761            skip_patterns: false,
762            exact_numeric: None,
763            exact_date_matches: None,
764            locale: None,
765        });
766
767        match &profile.stats {
768            ColumnStats::Boolean(b) => {
769                assert_eq!(b.true_count, 2);
770                assert_eq!(b.false_count, 1);
771                assert!((b.true_ratio - 2.0 / 3.0).abs() < 0.001);
772            }
773            other => panic!("expected Boolean stats, got {:?}", other),
774        }
775    }
776
777    #[test]
778    fn test_skip_statistics() {
779        let samples = vec!["10".to_string(), "20".to_string(), "30".to_string()];
780        let profile = build_column_profile(ColumnProfileInput {
781            name: "num".to_string(),
782            data_type: DataType::Integer,
783            total_count: 3,
784            null_count: 0,
785            unique_count: Some(3),
786            unique_count_is_approximate: Some(false),
787            unique_count_lower_bound: None,
788            sample_values: &samples,
789            text_lengths: None,
790            boolean_counts: None,
791            skip_statistics: true,
792            skip_patterns: false,
793            exact_numeric: None,
794            exact_date_matches: None,
795            locale: None,
796        });
797
798        assert!(matches!(profile.stats, ColumnStats::None));
799        assert_eq!(profile.data_type, DataType::Integer);
800    }
801
802    #[test]
803    fn test_skip_patterns() {
804        let samples = vec!["hello".to_string(), "world".to_string()];
805        let profile = build_column_profile(ColumnProfileInput {
806            name: "text".to_string(),
807            data_type: DataType::String,
808            total_count: 2,
809            null_count: 0,
810            unique_count: Some(2),
811            unique_count_is_approximate: Some(false),
812            unique_count_lower_bound: None,
813            sample_values: &samples,
814            text_lengths: None,
815            boolean_counts: None,
816            skip_statistics: false,
817            skip_patterns: true,
818            exact_numeric: None,
819            exact_date_matches: None,
820            locale: None,
821        });
822
823        // skip_patterns must be distinguishable from "scanned, nothing matched",
824        // otherwise downstream redaction gates cannot fail closed.
825        assert!(profile.patterns.is_none());
826        assert!(matches!(profile.stats, ColumnStats::Text(_)));
827    }
828
829    #[test]
830    fn test_scanned_without_matches_is_not_none() {
831        // The counterpart to `test_skip_patterns`: a column that *was* scanned
832        // and matched nothing yields `Some([])`, which downstream consumers may
833        // safely read as "no sensitive data here".
834        let samples = vec!["hello".to_string(), "world".to_string()];
835        let profile = build_column_profile(ColumnProfileInput {
836            name: "text".to_string(),
837            data_type: DataType::String,
838            total_count: 2,
839            null_count: 0,
840            unique_count: Some(2),
841            unique_count_is_approximate: Some(false),
842            unique_count_lower_bound: None,
843            sample_values: &samples,
844            text_lengths: None,
845            boolean_counts: None,
846            skip_statistics: false,
847            skip_patterns: false,
848            exact_numeric: None,
849            exact_date_matches: None,
850            locale: None,
851        });
852
853        assert!(profile.patterns.is_some_and(|p| p.is_empty()));
854    }
855
856    #[test]
857    fn test_all_packs_default() {
858        let samples = vec!["42".to_string(), "99".to_string()];
859        let profile = build_column_profile(ColumnProfileInput {
860            name: "val".to_string(),
861            data_type: DataType::Integer,
862            total_count: 2,
863            null_count: 0,
864            unique_count: Some(2),
865            unique_count_is_approximate: Some(false),
866            unique_count_lower_bound: None,
867            sample_values: &samples,
868            text_lengths: None,
869            boolean_counts: None,
870            skip_statistics: false,
871            skip_patterns: false,
872            exact_numeric: None,
873            exact_date_matches: None,
874            locale: None,
875        });
876
877        assert!(matches!(profile.stats, ColumnStats::Numeric(_)));
878        assert_eq!(profile.data_type, DataType::Integer);
879    }
880}