1use 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
26pub 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 pub unique_count_is_approximate: Option<bool>,
37 pub unique_count_lower_bound: Option<usize>,
41 pub sample_values: &'a [String],
42 pub text_lengths: Option<TextLengths>,
45 pub boolean_counts: Option<(usize, usize)>,
47 pub skip_statistics: bool,
49 pub skip_patterns: bool,
51 pub locale: Option<Locale>,
53 pub exact_numeric: Option<ExactNumericAggregates>,
61 pub exact_date_matches: Option<usize>,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq)]
70pub struct ExactNumericAggregates {
71 pub min: f64,
72 pub max: f64,
73 pub mean: f64,
74 pub std_dev: f64,
76 pub variance: f64,
78 pub count: usize,
80}
81
82pub struct TextLengths {
84 pub min_length: usize,
85 pub max_length: usize,
86 pub avg_length: f64,
87}
88
89pub 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 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 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 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 type_homogeneity: Some(classify_lexical_forms(input.sample_values)),
253 locale_number_count: Some(count_locale_numbers(input.sample_values)),
255 stats,
256 patterns,
257 }
258}
259
260pub 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
283pub 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
316pub 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
332pub 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
359pub 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
377pub 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
416pub 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 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 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 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
473pub 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 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 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 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 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 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 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 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}