1use std::collections::HashMap;
9
10use dataprof_core::{
11 BooleanStats, ColumnProfile, ColumnStats, DataType, DateTimeStats, Locale, SemanticHints,
12 TextStats,
13};
14use dataprof_metrics::{
15 analysis::inference::{
16 classify_lexical_forms, count_locale_numbers, is_integer_token, is_null_like_token,
17 parse_strict_boolean_token,
18 },
19 analysis::patterns::looks_like_date,
20 calculate_datetime_stats, calculate_text_stats, detect_patterns,
21 stats::numeric::{calculate_coefficient_of_variation, compute_numeric_stats_with_parsed_count},
22};
23
24use crate::streaming_stats::{StreamingColumnCollection, StreamingStatistics};
25
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 = if input.total_count == 0 {
235 ColumnStats::None
236 } else {
237 stats
238 };
239
240 let patterns = if input.skip_patterns {
241 None
242 } else {
243 Some(detect_patterns(input.sample_values, input.locale))
244 };
245
246 ColumnProfile {
247 name: input.name,
248 data_type: input.data_type,
249 null_count: input.null_count,
250 total_count: input.total_count,
251 unique_count: input.unique_count,
252 unique_count_is_approximate: input.unique_count_is_approximate,
253 unique_count_lower_bound: input.unique_count_lower_bound,
254 invalid_count,
255 type_homogeneity: Some(classify_lexical_forms(input.sample_values)),
261 locale_number_count: Some(count_locale_numbers(input.sample_values)),
263 stats,
264 patterns,
265 }
266}
267
268pub fn nested_column_profile(name: String, total_count: usize, null_count: usize) -> ColumnProfile {
275 ColumnProfile {
276 name,
277 data_type: DataType::Nested,
278 null_count,
279 total_count,
280 unique_count: None,
281 unique_count_is_approximate: None,
282 unique_count_lower_bound: None,
283 invalid_count: None,
284 type_homogeneity: None,
285 locale_number_count: None,
286 stats: ColumnStats::None,
287 patterns: None,
288 }
289}
290
291pub fn mark_container_columns(
307 profiles: Vec<ColumnProfile>,
308 container_counts: &HashMap<String, usize>,
309) -> Vec<ColumnProfile> {
310 profiles
311 .into_iter()
312 .map(|profile| {
313 let containers = container_counts.get(&profile.name).copied().unwrap_or(0);
314 let non_null = profile.total_count - profile.null_count;
315 if containers > 0 && containers == non_null {
316 nested_column_profile(profile.name, profile.total_count, profile.null_count)
317 } else {
318 profile
319 }
320 })
321 .collect()
322}
323
324pub fn profiles_from_streaming(
326 column_stats: &StreamingColumnCollection,
327 skip_statistics: bool,
328 skip_patterns: bool,
329 locale: Option<Locale>,
330) -> Vec<ColumnProfile> {
331 profiles_from_streaming_with_hints(
332 column_stats,
333 skip_statistics,
334 skip_patterns,
335 locale,
336 &SemanticHints::default(),
337 )
338}
339
340pub fn profiles_from_streaming_with_hints(
342 column_stats: &StreamingColumnCollection,
343 skip_statistics: bool,
344 skip_patterns: bool,
345 locale: Option<Locale>,
346 semantic_hints: &SemanticHints,
347) -> Vec<ColumnProfile> {
348 let mut profiles = Vec::new();
349
350 for column_name in column_stats.column_names() {
351 if let Some(stats) = column_stats.get_column_stats(&column_name) {
352 let profile = profile_from_stats_with_hints(
353 &column_name,
354 stats,
355 skip_statistics,
356 skip_patterns,
357 locale,
358 semantic_hints,
359 );
360 profiles.push(profile);
361 }
362 }
363
364 profiles
365}
366
367pub fn profile_from_stats(
369 name: &str,
370 stats: &StreamingStatistics,
371 skip_statistics: bool,
372 skip_patterns: bool,
373 locale: Option<Locale>,
374) -> ColumnProfile {
375 profile_from_stats_with_hints(
376 name,
377 stats,
378 skip_statistics,
379 skip_patterns,
380 locale,
381 &SemanticHints::default(),
382 )
383}
384
385pub fn profile_from_stats_with_hints(
387 name: &str,
388 stats: &StreamingStatistics,
389 skip_statistics: bool,
390 skip_patterns: bool,
391 locale: Option<Locale>,
392 semantic_hints: &SemanticHints,
393) -> ColumnProfile {
394 let data_type = if semantic_hints.is_identifier_column(name) {
395 DataType::Identifier
396 } else {
397 infer_data_type_streaming(stats)
398 };
399 let text_stats = stats.text_length_stats();
400
401 build_column_profile(ColumnProfileInput {
402 name: name.to_string(),
403 data_type,
404 total_count: stats.count,
405 null_count: stats.null_count,
406 unique_count: Some(stats.unique_count()),
407 unique_count_is_approximate: Some(stats.unique_count_is_approximate()),
408 unique_count_lower_bound: Some(stats.unique_count_lower_bound()),
409 sample_values: stats.sample_values(),
410 text_lengths: Some(TextLengths {
411 min_length: text_stats.min_length,
412 max_length: text_stats.max_length,
413 avg_length: text_stats.avg_length,
414 }),
415 boolean_counts: None,
416 skip_statistics,
417 skip_patterns,
418 locale,
419 exact_numeric: stats.exact_numeric_aggregates(),
420 exact_date_matches: Some(stats.date_match_count()),
421 })
422}
423
424pub fn infer_data_type_streaming(stats: &StreamingStatistics) -> DataType {
426 let sample_values = stats.sample_values();
427 let non_empty: Vec<&String> = sample_values
428 .iter()
429 .filter(|s| !is_null_like_token(s.trim()))
430 .collect();
431
432 if !non_empty.is_empty() {
433 let all_integers = non_empty.iter().all(|s| is_integer_token(s.trim()));
434 if all_integers {
435 return DataType::Integer;
436 }
437
438 let numeric_count = non_empty
442 .iter()
443 .filter(|s| s.trim().parse::<f64>().is_ok())
444 .count();
445 if numeric_count as f64 / non_empty.len() as f64 > 0.8 {
446 return DataType::Float;
447 }
448
449 let date_like_count = non_empty
455 .iter()
456 .take(100)
457 .filter(|s| looks_like_date(s.trim()))
458 .count();
459
460 if date_like_count as f64 / non_empty.len().min(100) as f64 > 0.7 {
461 return DataType::Date;
462 }
463
464 let bool_count = non_empty
465 .iter()
466 .filter(|s| parse_strict_boolean_token(s.trim()).is_some())
467 .count();
468
469 if bool_count as f64 / non_empty.len() as f64 >= 0.9 {
470 return DataType::Boolean;
471 }
472 }
473
474 DataType::String
475}
476
477pub fn quality_check_samples(
480 column_stats: &StreamingColumnCollection,
481) -> HashMap<String, Vec<String>> {
482 let mut samples = HashMap::new();
483
484 for column_name in column_stats.column_names() {
485 if let Some(stats) = column_stats.get_column_stats(&column_name) {
486 let sample_values: Vec<String> = stats.sample_values().to_vec();
487 samples.insert(column_name, sample_values);
488 }
489 }
490
491 samples
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::streaming_stats::StreamingColumnCollection;
498
499 #[test]
500 fn test_profiles_from_streaming() {
501 let mut collection = StreamingColumnCollection::new();
502 let headers = vec!["name".to_string(), "age".to_string()];
503
504 collection.process_record(&headers, vec!["Alice".to_string(), "30".to_string()]);
505 collection.process_record(&headers, vec!["Bob".to_string(), "25".to_string()]);
506 collection.process_record(&headers, vec!["Charlie".to_string(), "35".to_string()]);
507
508 let profiles = profiles_from_streaming(&collection, false, false, None);
509 assert_eq!(profiles.len(), 2);
510
511 let age = profiles.iter().find(|p| p.name == "age").unwrap();
512 assert_eq!(age.data_type, DataType::Integer);
513 assert_eq!(age.total_count, 3);
514 }
515
516 #[test]
517 fn test_all_non_finite_tokens_keep_streaming_float_type() {
518 let mut collection = StreamingColumnCollection::new();
519 let headers = vec!["value".to_string()];
520
521 collection.process_record(&headers, vec!["Infinity".to_string()]);
522 collection.process_record(&headers, vec!["-inf".to_string()]);
523
524 let profiles = profiles_from_streaming(&collection, false, false, None);
525 let value = profiles
526 .iter()
527 .find(|profile| profile.name == "value")
528 .expect("value profile");
529
530 assert_eq!(value.data_type, DataType::Float);
531 assert_eq!(value.invalid_count, Some(2));
532 assert!(matches!(value.stats, ColumnStats::None));
533 }
534
535 #[test]
536 fn type_homogeneity_is_classified_from_the_retained_sample() {
537 let samples = ["1", "2", "junk", ""].map(String::from).to_vec();
541 let profile = build_column_profile(ColumnProfileInput {
542 name: "v".to_string(),
543 data_type: DataType::String,
544 total_count: 4_000,
545 null_count: 1_000,
546 unique_count: Some(3),
547 unique_count_is_approximate: Some(false),
548 unique_count_lower_bound: None,
549 sample_values: &samples,
550 text_lengths: None,
551 boolean_counts: None,
552 skip_statistics: true,
555 skip_patterns: true,
556 exact_numeric: None,
557 exact_date_matches: None,
558 locale: None,
559 });
560
561 let counts = profile
562 .type_homogeneity
563 .expect("classification runs on every column");
564 assert_eq!(counts.numeric, 2);
565 assert_eq!(counts.text, 1);
566 assert_eq!(counts.classified_count(), 3, "null-like values are skipped");
567 assert!(
568 counts.classified_count() < profile.total_count - profile.null_count,
569 "a sample-derived count must read as short of the column"
570 );
571 }
572
573 #[test]
574 fn test_quality_check_samples() {
575 let mut collection = StreamingColumnCollection::new();
576 let headers = vec!["col".to_string()];
577
578 collection.process_record(&headers, vec!["val1".to_string()]);
579 collection.process_record(&headers, vec!["val2".to_string()]);
580
581 let samples = quality_check_samples(&collection);
582 assert!(samples.contains_key("col"));
583 assert_eq!(samples["col"].len(), 2);
584 }
585
586 #[test]
587 fn typed_columns_without_parsed_values_have_no_statistics() {
588 for data_type in [DataType::Integer, DataType::Float, DataType::Boolean] {
589 for values in [["", ""], ["junk", "Infinity"]] {
590 let samples = values.map(String::from);
591 let null_count = if values[0].is_empty() { 2 } else { 0 };
592 let profile = build_column_profile(ColumnProfileInput {
593 name: "v".to_string(),
594 data_type: data_type.clone(),
595 total_count: 2,
596 null_count,
597 unique_count: None,
598 unique_count_is_approximate: None,
599 unique_count_lower_bound: None,
600 sample_values: &samples,
601 text_lengths: None,
602 boolean_counts: None,
603 skip_statistics: false,
604 skip_patterns: false,
605 locale: None,
606 exact_numeric: None,
607 exact_date_matches: None,
608 });
609
610 assert!(matches!(profile.stats, ColumnStats::None));
611 assert_eq!(profile.data_type, data_type);
612 assert_eq!(profile.total_count, 2);
613 assert_eq!(profile.null_count, null_count);
614 if matches!(data_type, DataType::Integer | DataType::Float) {
615 assert_eq!(profile.invalid_count, Some(2 - null_count));
616 }
617 assert!(profile.patterns.is_some());
618 }
619 }
620 }
621
622 #[test]
623 fn exact_zero_statistics_survive_a_sample_without_parsed_values() {
624 for data_type in [DataType::Integer, DataType::Boolean] {
627 let samples = [String::new()];
628 let profile = build_column_profile(ColumnProfileInput {
629 name: "v".to_string(),
630 data_type: data_type.clone(),
631 total_count: 3,
632 null_count: 1,
633 unique_count: None,
634 unique_count_is_approximate: None,
635 unique_count_lower_bound: None,
636 sample_values: &samples,
637 text_lengths: None,
638 boolean_counts: Some((0, 2)),
639 skip_statistics: false,
640 skip_patterns: true,
641 locale: None,
642 exact_numeric: Some(ExactNumericAggregates {
643 min: 0.0,
644 max: 0.0,
645 mean: 0.0,
646 std_dev: 0.0,
647 variance: 0.0,
648 count: 2,
649 }),
650 exact_date_matches: None,
651 });
652
653 match profile.stats {
654 ColumnStats::Numeric(stats) => {
655 assert_eq!(stats.min, 0.0);
656 assert_eq!(stats.mean, 0.0);
657 assert_eq!(stats.is_approximate, Some(true));
658 assert_eq!(profile.invalid_count, Some(0));
659 }
660 ColumnStats::Boolean(stats) => {
661 assert_eq!(stats.true_ratio, 0.0);
662 assert_eq!(stats.false_count, 2);
663 }
664 other => panic!("expected measured zero statistics, got {other:?}"),
665 }
666 }
667 }
668
669 #[test]
670 fn an_overflowing_exact_spread_clears_the_sample_shape() {
671 let samples: Vec<String> = ["5", "0.5", "5", "7", "3"]
674 .iter()
675 .map(|value| value.to_string())
676 .collect();
677 let profile = build_column_profile(ColumnProfileInput {
678 name: "x".to_string(),
679 data_type: DataType::Float,
680 total_count: 20_000,
681 null_count: 0,
682 unique_count: None,
683 unique_count_is_approximate: None,
684 unique_count_lower_bound: None,
685 sample_values: &samples,
686 text_lengths: None,
687 boolean_counts: None,
688 skip_statistics: false,
689 skip_patterns: true,
690 locale: None,
691 exact_numeric: Some(ExactNumericAggregates {
692 min: -1e308,
693 max: 1e308,
694 mean: 2.9167,
695 std_dev: f64::NAN,
696 variance: f64::NAN,
697 count: 20_000,
698 }),
699 exact_date_matches: None,
700 });
701
702 let ColumnStats::Numeric(stats) = profile.stats else {
703 panic!("expected numeric statistics, got {:?}", profile.stats);
704 };
705 assert_eq!(stats.variance, None);
706 assert_eq!(stats.std_dev, None);
707 assert_eq!(stats.coefficient_of_variation, None);
708 assert_eq!(stats.skewness, None);
709 assert_eq!(stats.kurtosis, None);
710 assert_eq!(stats.median, Some(5.0));
712 assert_eq!(stats.is_approximate, Some(true));
713 }
714
715 #[test]
716 fn test_boolean_stats_with_counts() {
717 let samples = vec!["True".to_string(), "False".to_string(), "True".to_string()];
718 let profile = build_column_profile(ColumnProfileInput {
719 name: "flag".to_string(),
720 data_type: DataType::Boolean,
721 total_count: 3,
722 null_count: 0,
723 unique_count: Some(2),
724 unique_count_is_approximate: Some(false),
725 unique_count_lower_bound: None,
726 sample_values: &samples,
727 text_lengths: None,
728 boolean_counts: Some((2, 1)),
729 skip_statistics: false,
730 skip_patterns: false,
731 exact_numeric: None,
732 exact_date_matches: None,
733 locale: None,
734 });
735
736 match &profile.stats {
737 ColumnStats::Boolean(b) => {
738 assert_eq!(b.true_count, 2);
739 assert_eq!(b.false_count, 1);
740 assert!((b.true_ratio - 2.0 / 3.0).abs() < 0.001);
741 }
742 other => panic!("expected Boolean stats, got {:?}", other),
743 }
744 }
745
746 #[test]
747 fn test_boolean_stats_fallback_case_insensitive() {
748 let samples = vec![
749 "true".to_string(),
750 "FALSE".to_string(),
751 " True ".to_string(),
752 ];
753 let profile = build_column_profile(ColumnProfileInput {
754 name: "flag".to_string(),
755 data_type: DataType::Boolean,
756 total_count: 3,
757 null_count: 0,
758 unique_count: Some(2),
759 unique_count_is_approximate: Some(false),
760 unique_count_lower_bound: None,
761 sample_values: &samples,
762 text_lengths: None,
763 boolean_counts: None,
764 skip_statistics: false,
765 skip_patterns: false,
766 exact_numeric: None,
767 exact_date_matches: None,
768 locale: None,
769 });
770
771 match &profile.stats {
772 ColumnStats::Boolean(b) => {
773 assert_eq!(b.true_count, 2);
774 assert_eq!(b.false_count, 1);
775 assert!((b.true_ratio - 2.0 / 3.0).abs() < 0.001);
776 }
777 other => panic!("expected Boolean stats, got {:?}", other),
778 }
779 }
780
781 #[test]
782 fn test_skip_statistics() {
783 let samples = vec!["10".to_string(), "20".to_string(), "30".to_string()];
784 let profile = build_column_profile(ColumnProfileInput {
785 name: "num".to_string(),
786 data_type: DataType::Integer,
787 total_count: 3,
788 null_count: 0,
789 unique_count: Some(3),
790 unique_count_is_approximate: Some(false),
791 unique_count_lower_bound: None,
792 sample_values: &samples,
793 text_lengths: None,
794 boolean_counts: None,
795 skip_statistics: true,
796 skip_patterns: false,
797 exact_numeric: None,
798 exact_date_matches: None,
799 locale: None,
800 });
801
802 assert!(matches!(profile.stats, ColumnStats::None));
803 assert_eq!(profile.data_type, DataType::Integer);
804 }
805
806 #[test]
807 fn test_skip_patterns() {
808 let samples = vec!["hello".to_string(), "world".to_string()];
809 let profile = build_column_profile(ColumnProfileInput {
810 name: "text".to_string(),
811 data_type: DataType::String,
812 total_count: 2,
813 null_count: 0,
814 unique_count: Some(2),
815 unique_count_is_approximate: Some(false),
816 unique_count_lower_bound: None,
817 sample_values: &samples,
818 text_lengths: None,
819 boolean_counts: None,
820 skip_statistics: false,
821 skip_patterns: true,
822 exact_numeric: None,
823 exact_date_matches: None,
824 locale: None,
825 });
826
827 assert!(profile.patterns.is_none());
830 assert!(matches!(profile.stats, ColumnStats::Text(_)));
831 }
832
833 #[test]
834 fn test_scanned_without_matches_is_not_none() {
835 let samples = vec!["hello".to_string(), "world".to_string()];
839 let profile = build_column_profile(ColumnProfileInput {
840 name: "text".to_string(),
841 data_type: DataType::String,
842 total_count: 2,
843 null_count: 0,
844 unique_count: Some(2),
845 unique_count_is_approximate: Some(false),
846 unique_count_lower_bound: None,
847 sample_values: &samples,
848 text_lengths: None,
849 boolean_counts: None,
850 skip_statistics: false,
851 skip_patterns: false,
852 exact_numeric: None,
853 exact_date_matches: None,
854 locale: None,
855 });
856
857 assert!(profile.patterns.is_some_and(|p| p.is_empty()));
858 }
859
860 #[test]
861 fn test_all_packs_default() {
862 let samples = vec!["42".to_string(), "99".to_string()];
863 let profile = build_column_profile(ColumnProfileInput {
864 name: "val".to_string(),
865 data_type: DataType::Integer,
866 total_count: 2,
867 null_count: 0,
868 unique_count: Some(2),
869 unique_count_is_approximate: Some(false),
870 unique_count_lower_bound: None,
871 sample_values: &samples,
872 text_lengths: None,
873 boolean_counts: None,
874 skip_statistics: false,
875 skip_patterns: false,
876 exact_numeric: None,
877 exact_date_matches: None,
878 locale: None,
879 });
880
881 assert!(matches!(profile.stats, ColumnStats::Numeric(_)));
882 assert_eq!(profile.data_type, DataType::Integer);
883 }
884}