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