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, 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
25pub 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 pub unique_count_is_approximate: Option<bool>,
36 pub sample_values: &'a [String],
37 pub text_lengths: Option<TextLengths>,
40 pub boolean_counts: Option<(usize, usize)>,
42 pub skip_statistics: bool,
44 pub skip_patterns: bool,
46 pub locale: Option<Locale>,
48 pub exact_numeric: Option<ExactNumericAggregates>,
56 pub exact_date_matches: Option<usize>,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct ExactNumericAggregates {
66 pub min: f64,
67 pub max: f64,
68 pub mean: f64,
69 pub std_dev: f64,
71 pub variance: f64,
73 pub count: usize,
75}
76
77pub struct TextLengths {
79 pub min_length: usize,
80 pub max_length: usize,
81 pub avg_length: f64,
82}
83
84pub 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 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 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 type_homogeneity: Some(classify_lexical_forms(input.sample_values)),
221 stats,
222 patterns,
223 }
224}
225
226pub 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
242pub 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
269pub 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
287pub 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
325pub 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 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
373pub 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 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 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 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 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}