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, is_integer_token, is_null_like_token, parse_strict_boolean_token,
17    },
18    analysis::patterns::looks_like_date,
19    calculate_datetime_stats, calculate_text_stats, detect_patterns,
20    stats::numeric::{calculate_coefficient_of_variation, compute_numeric_stats_with_parsed_count},
21};
22
23use crate::streaming_stats::{StreamingColumnCollection, StreamingStatistics};
24
25/// Inputs that every engine can provide for centralized profile construction.
26pub struct ColumnProfileInput<'a> {
27    pub name: String,
28    pub data_type: DataType,
29    pub total_count: usize,
30    pub null_count: usize,
31    pub unique_count: Option<usize>,
32    /// Whether `unique_count` is an approximate (HLL) estimate. `None` mirrors
33    /// `unique_count: None`; `Some(false)` for exact counts; `Some(true)` once
34    /// the engine's cardinality estimator has spilled to its HLL sketch.
35    pub unique_count_is_approximate: Option<bool>,
36    pub sample_values: &'a [String],
37    /// Pre-computed text lengths for engines that track them incrementally.
38    /// When `Some`, text stats are built from these instead of re-scanning samples.
39    pub text_lengths: Option<TextLengths>,
40    /// Pre-computed boolean counts (true_count, false_count) for boolean columns.
41    pub boolean_counts: Option<(usize, usize)>,
42    /// When true, skip statistics computation (produce `ColumnStats::None`).
43    pub skip_statistics: bool,
44    /// When true, skip pattern detection (produce `patterns: None`).
45    pub skip_patterns: bool,
46    /// Optional locale for pattern detection (e.g. "IT", "US").
47    pub locale: Option<Locale>,
48    /// Exact aggregates over every numeric value the engine streamed.
49    ///
50    /// When present, these override the sample-derived `min`, `max`, `mean`,
51    /// `std_dev`, and `variance` on numeric columns, so those fields stay
52    /// exact even when `sample_values` no longer covers the full stream.
53    /// `None` means `sample_values` *is* the full data (in-memory sources)
54    /// or the engine has no exact accumulators for the column.
55    pub exact_numeric: Option<ExactNumericAggregates>,
56    /// Number of values over the full stream accepted by the temporal parser.
57    /// `None` means `sample_values` is the complete in-memory column.
58    pub exact_date_matches: Option<usize>,
59}
60
61/// Exact streaming aggregates for a numeric column: O(1)-memory statistics that an
62/// engine computed over the entire stream, as opposed to the bounded
63/// `sample_values` it retained.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct ExactNumericAggregates {
66    pub min: f64,
67    pub max: f64,
68    pub mean: f64,
69    /// Standard deviation with the unbiased (n-1) denominator.
70    pub std_dev: f64,
71    /// Variance with the unbiased (n-1) denominator.
72    pub variance: f64,
73    /// Number of parsed numeric values covered by these aggregates.
74    pub count: usize,
75}
76
77/// Pre-computed text length stats from streaming/columnar engines.
78pub struct TextLengths {
79    pub min_length: usize,
80    pub max_length: usize,
81    pub avg_length: f64,
82}
83
84/// Build a [`ColumnProfile`] from engine-agnostic inputs.
85///
86/// This is the single canonical construction path. Engines provide
87/// pre-inferred `DataType`, counters, sample values, and optionally
88/// pre-computed text lengths; this function handles stats calculation
89/// and pattern detection.
90pub fn build_column_profile(input: ColumnProfileInput<'_>) -> ColumnProfile {
91    let mut invalid_count = None;
92    let stats = if input.skip_statistics {
93        ColumnStats::None
94    } else {
95        match input.data_type {
96            DataType::Integer | DataType::Float => {
97                let (mut numeric, sampled_numeric) =
98                    compute_numeric_stats_with_parsed_count(input.sample_values);
99                // Values the statistics actually cover. With exact stream
100                // aggregates that is the engine's full parsed count; without
101                // them the sample *is* the full data (in-memory sources).
102                let parsed_total = match &input.exact_numeric {
103                    Some(exact) => exact.count,
104                    None => sampled_numeric,
105                };
106                invalid_count = Some(
107                    input
108                        .total_count
109                        .saturating_sub(input.null_count)
110                        .saturating_sub(parsed_total),
111                );
112                if let Some(exact) = &input.exact_numeric {
113                    numeric.min = exact.min;
114                    numeric.max = exact.max;
115                    numeric.mean = exact.mean;
116                    numeric.std_dev = exact.std_dev;
117                    numeric.variance = exact.variance;
118                    numeric.coefficient_of_variation =
119                        calculate_coefficient_of_variation(exact.std_dev, exact.mean);
120                    // Order statistics (median, quartiles, mode, skewness,
121                    // kurtosis, outliers) still come from the retained sample;
122                    // disclose that whenever the sample no longer covers every
123                    // numeric value the exact aggregates saw.
124                    if exact.count > sampled_numeric {
125                        numeric.is_approximate = Some(true);
126                    }
127                }
128                ColumnStats::Numeric(numeric)
129            }
130            DataType::Date => {
131                let parsed_dates = input.exact_date_matches.unwrap_or_else(|| {
132                    input
133                        .sample_values
134                        .iter()
135                        .filter(|value| {
136                            dataprof_metrics::value_matches_hint(
137                                value,
138                                dataprof_core::SemanticHintKind::Temporal,
139                            )
140                        })
141                        .count()
142                });
143                invalid_count = Some(
144                    input
145                        .total_count
146                        .saturating_sub(input.null_count)
147                        .saturating_sub(parsed_dates),
148                );
149                if !input.sample_values.is_empty() {
150                    calculate_datetime_stats(input.sample_values)
151                } else if let Some(tl) = &input.text_lengths {
152                    ColumnStats::Text(TextStats::from_lengths(
153                        tl.min_length,
154                        tl.max_length,
155                        tl.avg_length,
156                    ))
157                } else {
158                    ColumnStats::DateTime(DateTimeStats::empty())
159                }
160            }
161            DataType::Boolean => {
162                let (true_count, false_count) = input.boolean_counts.unwrap_or_else(|| {
163                    let tc = input
164                        .sample_values
165                        .iter()
166                        .filter(|v| parse_strict_boolean_token(v.trim()) == Some(true))
167                        .count();
168                    let fc = input
169                        .sample_values
170                        .iter()
171                        .filter(|v| parse_strict_boolean_token(v.trim()) == Some(false))
172                        .count();
173                    (tc, fc)
174                });
175                let total = true_count + false_count;
176                let true_ratio = if total > 0 {
177                    true_count as f64 / total as f64
178                } else {
179                    0.0
180                };
181                ColumnStats::Boolean(BooleanStats {
182                    true_count,
183                    false_count,
184                    true_ratio,
185                })
186            }
187            DataType::String | DataType::Identifier => {
188                if let Some(tl) = &input.text_lengths {
189                    ColumnStats::Text(TextStats::from_lengths(
190                        tl.min_length,
191                        tl.max_length,
192                        tl.avg_length,
193                    ))
194                } else {
195                    calculate_text_stats(input.sample_values)
196                }
197            }
198        }
199    };
200
201    let patterns = if input.skip_patterns {
202        None
203    } else {
204        Some(detect_patterns(input.sample_values, input.locale))
205    };
206
207    ColumnProfile {
208        name: input.name,
209        data_type: input.data_type,
210        null_count: input.null_count,
211        total_count: input.total_count,
212        unique_count: input.unique_count,
213        unique_count_is_approximate: input.unique_count_is_approximate,
214        invalid_count,
215        // Classified over the retained sample, so the counts are bounded by the
216        // engine's reservoir on a large source. `classified_count()` against
217        // `total_count - null_count` is what tells a reader which happened.
218        // Not gated by `skip_statistics`: which forms a column holds is
219        // schema-level evidence like the inferred type, not a statistic.
220        type_homogeneity: Some(classify_lexical_forms(input.sample_values)),
221        stats,
222        patterns,
223    }
224}
225
226/// Convert all columns in a [`StreamingColumnCollection`] into [`ColumnProfile`]s.
227pub fn profiles_from_streaming(
228    column_stats: &StreamingColumnCollection,
229    skip_statistics: bool,
230    skip_patterns: bool,
231    locale: Option<Locale>,
232) -> Vec<ColumnProfile> {
233    profiles_from_streaming_with_hints(
234        column_stats,
235        skip_statistics,
236        skip_patterns,
237        locale,
238        &SemanticHints::default(),
239    )
240}
241
242/// Convert all columns into [`ColumnProfile`]s while applying semantic hints.
243pub fn profiles_from_streaming_with_hints(
244    column_stats: &StreamingColumnCollection,
245    skip_statistics: bool,
246    skip_patterns: bool,
247    locale: Option<Locale>,
248    semantic_hints: &SemanticHints,
249) -> Vec<ColumnProfile> {
250    let mut profiles = Vec::new();
251
252    for column_name in column_stats.column_names() {
253        if let Some(stats) = column_stats.get_column_stats(&column_name) {
254            let profile = profile_from_stats_with_hints(
255                &column_name,
256                stats,
257                skip_statistics,
258                skip_patterns,
259                locale,
260                semantic_hints,
261            );
262            profiles.push(profile);
263        }
264    }
265
266    profiles
267}
268
269/// Convert a single column's [`StreamingStatistics`] into a [`ColumnProfile`].
270pub fn profile_from_stats(
271    name: &str,
272    stats: &StreamingStatistics,
273    skip_statistics: bool,
274    skip_patterns: bool,
275    locale: Option<Locale>,
276) -> ColumnProfile {
277    profile_from_stats_with_hints(
278        name,
279        stats,
280        skip_statistics,
281        skip_patterns,
282        locale,
283        &SemanticHints::default(),
284    )
285}
286
287/// Convert a single column into a [`ColumnProfile`] while applying semantic hints.
288pub fn profile_from_stats_with_hints(
289    name: &str,
290    stats: &StreamingStatistics,
291    skip_statistics: bool,
292    skip_patterns: bool,
293    locale: Option<Locale>,
294    semantic_hints: &SemanticHints,
295) -> ColumnProfile {
296    let data_type = if semantic_hints.is_identifier_column(name) {
297        DataType::Identifier
298    } else {
299        infer_data_type_streaming(stats)
300    };
301    let text_stats = stats.text_length_stats();
302
303    build_column_profile(ColumnProfileInput {
304        name: name.to_string(),
305        data_type,
306        total_count: stats.count,
307        null_count: stats.null_count,
308        unique_count: Some(stats.unique_count()),
309        unique_count_is_approximate: Some(stats.unique_count_is_approximate()),
310        sample_values: stats.sample_values(),
311        text_lengths: Some(TextLengths {
312            min_length: text_stats.min_length,
313            max_length: text_stats.max_length,
314            avg_length: text_stats.avg_length,
315        }),
316        boolean_counts: None,
317        skip_statistics,
318        skip_patterns,
319        locale,
320        exact_numeric: stats.exact_numeric_aggregates(),
321        exact_date_matches: Some(stats.date_match_count()),
322    })
323}
324
325/// Infer [`DataType`] from [`StreamingStatistics`] sample values.
326pub fn infer_data_type_streaming(stats: &StreamingStatistics) -> DataType {
327    let sample_values = stats.sample_values();
328    let non_empty: Vec<&String> = sample_values
329        .iter()
330        .filter(|s| !is_null_like_token(s.trim()))
331        .collect();
332
333    if !non_empty.is_empty() {
334        let all_integers = non_empty.iter().all(|s| is_integer_token(s.trim()));
335        if all_integers {
336            return DataType::Integer;
337        }
338
339        // Numeric lexical forms remain numeric even if none are finite enough
340        // to enter the streaming aggregates. `build_column_profile` reports
341        // those unusable values through `invalid_count`.
342        let numeric_count = non_empty
343            .iter()
344            .filter(|s| s.trim().parse::<f64>().is_ok())
345            .count();
346        if numeric_count as f64 / non_empty.len() as f64 > 0.8 {
347            return DataType::Float;
348        }
349
350        let date_like_count = non_empty
351            .iter()
352            .take(100)
353            .filter(|s| looks_like_date(s))
354            .count();
355
356        if date_like_count as f64 / non_empty.len().min(100) as f64 > 0.7 {
357            return DataType::Date;
358        }
359
360        let bool_count = non_empty
361            .iter()
362            .filter(|s| parse_strict_boolean_token(s.trim()).is_some())
363            .count();
364
365        if bool_count as f64 / non_empty.len() as f64 >= 0.9 {
366            return DataType::Boolean;
367        }
368    }
369
370    DataType::String
371}
372
373/// Build a sample `HashMap` from a [`StreamingColumnCollection`] suitable for
374/// `QualityMetrics::calculate_from_data()`.
375pub fn quality_check_samples(
376    column_stats: &StreamingColumnCollection,
377) -> HashMap<String, Vec<String>> {
378    let mut samples = HashMap::new();
379
380    for column_name in column_stats.column_names() {
381        if let Some(stats) = column_stats.get_column_stats(&column_name) {
382            let sample_values: Vec<String> = stats.sample_values().to_vec();
383            samples.insert(column_name, sample_values);
384        }
385    }
386
387    samples
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::streaming_stats::StreamingColumnCollection;
394
395    #[test]
396    fn test_profiles_from_streaming() {
397        let mut collection = StreamingColumnCollection::new();
398        let headers = vec!["name".to_string(), "age".to_string()];
399
400        collection.process_record(&headers, vec!["Alice".to_string(), "30".to_string()]);
401        collection.process_record(&headers, vec!["Bob".to_string(), "25".to_string()]);
402        collection.process_record(&headers, vec!["Charlie".to_string(), "35".to_string()]);
403
404        let profiles = profiles_from_streaming(&collection, false, false, None);
405        assert_eq!(profiles.len(), 2);
406
407        let age = profiles.iter().find(|p| p.name == "age").unwrap();
408        assert_eq!(age.data_type, DataType::Integer);
409        assert_eq!(age.total_count, 3);
410    }
411
412    #[test]
413    fn test_all_non_finite_tokens_keep_streaming_float_type() {
414        let mut collection = StreamingColumnCollection::new();
415        let headers = vec!["value".to_string()];
416
417        collection.process_record(&headers, vec!["Infinity".to_string()]);
418        collection.process_record(&headers, vec!["-inf".to_string()]);
419
420        let profiles = profiles_from_streaming(&collection, false, false, None);
421        let value = profiles
422            .iter()
423            .find(|profile| profile.name == "value")
424            .expect("value profile");
425
426        assert_eq!(value.data_type, DataType::Float);
427        assert_eq!(value.invalid_count, Some(2));
428        assert!(matches!(value.stats, ColumnStats::Numeric(_)));
429    }
430
431    #[test]
432    fn type_homogeneity_is_classified_from_the_retained_sample() {
433        // The streaming path only holds its reservoir, so the counts describe
434        // the sample. `classified_count()` short of the non-null total is the
435        // signal that the shares are sampled — nothing else discloses it.
436        let samples = ["1", "2", "junk", ""].map(String::from).to_vec();
437        let profile = build_column_profile(ColumnProfileInput {
438            name: "v".to_string(),
439            data_type: DataType::String,
440            total_count: 4_000,
441            null_count: 1_000,
442            unique_count: Some(3),
443            unique_count_is_approximate: Some(false),
444            sample_values: &samples,
445            text_lengths: None,
446            boolean_counts: None,
447            // Not gated by the analysis selection: a column's lexical makeup is
448            // schema-level evidence, like its inferred type.
449            skip_statistics: true,
450            skip_patterns: true,
451            exact_numeric: None,
452            exact_date_matches: None,
453            locale: None,
454        });
455
456        let counts = profile
457            .type_homogeneity
458            .expect("classification runs on every column");
459        assert_eq!(counts.numeric, 2);
460        assert_eq!(counts.text, 1);
461        assert_eq!(counts.classified_count(), 3, "null-like values are skipped");
462        assert!(
463            counts.classified_count() < profile.total_count - profile.null_count,
464            "a sample-derived count must read as short of the column"
465        );
466    }
467
468    #[test]
469    fn test_quality_check_samples() {
470        let mut collection = StreamingColumnCollection::new();
471        let headers = vec!["col".to_string()];
472
473        collection.process_record(&headers, vec!["val1".to_string()]);
474        collection.process_record(&headers, vec!["val2".to_string()]);
475
476        let samples = quality_check_samples(&collection);
477        assert!(samples.contains_key("col"));
478        assert_eq!(samples["col"].len(), 2);
479    }
480
481    #[test]
482    fn test_boolean_stats_with_counts() {
483        let samples = vec!["True".to_string(), "False".to_string(), "True".to_string()];
484        let profile = build_column_profile(ColumnProfileInput {
485            name: "flag".to_string(),
486            data_type: DataType::Boolean,
487            total_count: 3,
488            null_count: 0,
489            unique_count: Some(2),
490            unique_count_is_approximate: Some(false),
491            sample_values: &samples,
492            text_lengths: None,
493            boolean_counts: Some((2, 1)),
494            skip_statistics: false,
495            skip_patterns: false,
496            exact_numeric: None,
497            exact_date_matches: None,
498            locale: None,
499        });
500
501        match &profile.stats {
502            ColumnStats::Boolean(b) => {
503                assert_eq!(b.true_count, 2);
504                assert_eq!(b.false_count, 1);
505                assert!((b.true_ratio - 2.0 / 3.0).abs() < 0.001);
506            }
507            other => panic!("expected Boolean stats, got {:?}", other),
508        }
509    }
510
511    #[test]
512    fn test_boolean_stats_fallback_case_insensitive() {
513        let samples = vec![
514            "true".to_string(),
515            "FALSE".to_string(),
516            " True ".to_string(),
517        ];
518        let profile = build_column_profile(ColumnProfileInput {
519            name: "flag".to_string(),
520            data_type: DataType::Boolean,
521            total_count: 3,
522            null_count: 0,
523            unique_count: Some(2),
524            unique_count_is_approximate: Some(false),
525            sample_values: &samples,
526            text_lengths: None,
527            boolean_counts: None,
528            skip_statistics: false,
529            skip_patterns: false,
530            exact_numeric: None,
531            exact_date_matches: None,
532            locale: None,
533        });
534
535        match &profile.stats {
536            ColumnStats::Boolean(b) => {
537                assert_eq!(b.true_count, 2);
538                assert_eq!(b.false_count, 1);
539                assert!((b.true_ratio - 2.0 / 3.0).abs() < 0.001);
540            }
541            other => panic!("expected Boolean stats, got {:?}", other),
542        }
543    }
544
545    #[test]
546    fn test_skip_statistics() {
547        let samples = vec!["10".to_string(), "20".to_string(), "30".to_string()];
548        let profile = build_column_profile(ColumnProfileInput {
549            name: "num".to_string(),
550            data_type: DataType::Integer,
551            total_count: 3,
552            null_count: 0,
553            unique_count: Some(3),
554            unique_count_is_approximate: Some(false),
555            sample_values: &samples,
556            text_lengths: None,
557            boolean_counts: None,
558            skip_statistics: true,
559            skip_patterns: false,
560            exact_numeric: None,
561            exact_date_matches: None,
562            locale: None,
563        });
564
565        assert!(matches!(profile.stats, ColumnStats::None));
566        assert_eq!(profile.data_type, DataType::Integer);
567    }
568
569    #[test]
570    fn test_skip_patterns() {
571        let samples = vec!["hello".to_string(), "world".to_string()];
572        let profile = build_column_profile(ColumnProfileInput {
573            name: "text".to_string(),
574            data_type: DataType::String,
575            total_count: 2,
576            null_count: 0,
577            unique_count: Some(2),
578            unique_count_is_approximate: Some(false),
579            sample_values: &samples,
580            text_lengths: None,
581            boolean_counts: None,
582            skip_statistics: false,
583            skip_patterns: true,
584            exact_numeric: None,
585            exact_date_matches: None,
586            locale: None,
587        });
588
589        // skip_patterns must be distinguishable from "scanned, nothing matched",
590        // otherwise downstream redaction gates cannot fail closed.
591        assert!(profile.patterns.is_none());
592        assert!(matches!(profile.stats, ColumnStats::Text(_)));
593    }
594
595    #[test]
596    fn test_scanned_without_matches_is_not_none() {
597        // The counterpart to `test_skip_patterns`: a column that *was* scanned
598        // and matched nothing yields `Some([])`, which downstream consumers may
599        // safely read as "no sensitive data here".
600        let samples = vec!["hello".to_string(), "world".to_string()];
601        let profile = build_column_profile(ColumnProfileInput {
602            name: "text".to_string(),
603            data_type: DataType::String,
604            total_count: 2,
605            null_count: 0,
606            unique_count: Some(2),
607            unique_count_is_approximate: Some(false),
608            sample_values: &samples,
609            text_lengths: None,
610            boolean_counts: None,
611            skip_statistics: false,
612            skip_patterns: false,
613            exact_numeric: None,
614            exact_date_matches: None,
615            locale: None,
616        });
617
618        assert!(profile.patterns.is_some_and(|p| p.is_empty()));
619    }
620
621    #[test]
622    fn test_all_packs_default() {
623        let samples = vec!["42".to_string(), "99".to_string()];
624        let profile = build_column_profile(ColumnProfileInput {
625            name: "val".to_string(),
626            data_type: DataType::Integer,
627            total_count: 2,
628            null_count: 0,
629            unique_count: Some(2),
630            unique_count_is_approximate: Some(false),
631            sample_values: &samples,
632            text_lengths: None,
633            boolean_counts: None,
634            skip_statistics: false,
635            skip_patterns: false,
636            exact_numeric: None,
637            exact_date_matches: None,
638            locale: None,
639        });
640
641        assert!(matches!(profile.stats, ColumnStats::Numeric(_)));
642        assert_eq!(profile.data_type, DataType::Integer);
643    }
644}