Skip to main content

dataprof_core/
profile.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::classification::{DataType, TypeHomogeneity};
6use crate::pattern::Pattern;
7
8/// Profiling statistics for a single column.
9#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
10pub struct ColumnProfile {
11    pub name: String,
12    pub data_type: DataType,
13    pub null_count: usize,
14    pub total_count: usize,
15    pub unique_count: Option<usize>,
16    /// Whether `unique_count` is an approximate (HyperLogLog) estimate rather
17    /// than an exact distinct count.
18    ///
19    /// `None` when `unique_count` is `None` (never computed); `Some(false)` for
20    /// an exact count; `Some(true)` once the cardinality estimator has spilled
21    /// to its HLL sketch (~1% relative error). Consumers running key or
22    /// high-cardinality/uniqueness gates must treat `Some(true)` as "do not rely
23    /// on this as an exact integer" -- an exact-looking count with no provenance
24    /// is unsafe for those checks.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub unique_count_is_approximate: Option<bool>,
27    /// A distinct count the column is certain to reach when `unique_count` is
28    /// an estimate: the distinct values the exact set held when it was
29    /// dropped. `None` when no floor was recorded, which includes every exact
30    /// count (the count is its own floor) and every report read back from a
31    /// document.
32    ///
33    /// Not serialized: it exists so quality bounds computed during the run can
34    /// bound a key column's uniqueness with certainty (#819). A saved report
35    /// keeps the bounds themselves.
36    #[serde(skip)]
37    #[schemars(skip)]
38    pub unique_count_lower_bound: Option<usize>,
39    /// Non-null values that failed the column type's raw validity predicate:
40    /// non-finite or malformed numbers on numeric columns, and values that do
41    /// not parse directly as calendar dates on date columns. The date predicate
42    /// intentionally does not trim surrounding whitespace; descriptive date
43    /// statistics may normalize whitespace independently.
44    ///
45    /// For numeric columns, `mean`/`std_dev` cover
46    /// `total_count - null_count - invalid_count` values. For date columns,
47    /// this count audits the strict raw-value quality predicate. `None` means
48    /// the check did not run (another column type, or statistics skipped) —
49    /// never "no invalid values", which is `Some(0)`.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub invalid_count: Option<usize>,
52    /// How the column's non-null values distribute across lexical classes.
53    ///
54    /// `data_type` cannot answer "did this column have a dominant form?": a
55    /// column of names and a column that is 60% numbers are both `String`, and
56    /// `invalid_count` is absent on string columns by contract. This carries the
57    /// evidence, so a consumer can tell a textual column from one that defeated
58    /// type inference.
59    ///
60    /// Counted over the values the profiler retained — the engine's bounded
61    /// reservoir sample on a large source, the whole column on a small or
62    /// in-memory one. `classified_count()` against `total_count - null_count`
63    /// is what says which happened; treat the shares as sampled whenever it is
64    /// short.
65    ///
66    /// `None` means the classification did not run, never "one uniform class":
67    /// a column that was classified and had nothing to classify (all-null, or
68    /// zero rows) is `Some` with every count zero.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub type_homogeneity: Option<TypeHomogeneity>,
71    /// Of the values `type_homogeneity` counted as `text`, how many are
72    /// numbers written with a decimal comma or with digit-group separators:
73    /// `10,50`, `1.234,56`, `1,234.56`, `1'234.56`, `1 234,56` (#433).
74    ///
75    /// dataprof does not parse these as numbers, so they are in no numeric
76    /// statistic: a column of them is typed `string`, and a numeric column
77    /// counts them in `invalid_count`. The count does not say which
78    /// convention they follow; `1,234` is a thousand in one and a fraction in
79    /// another.
80    ///
81    /// Counted over the same values as `type_homogeneity`, and present
82    /// whenever it is. `None` means the count did not run, including a report
83    /// written before dataprof recorded it; `Some(0)` means none were found.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub locale_number_count: Option<usize>,
86    pub stats: ColumnStats,
87    /// Detected patterns, or `None` when pattern detection did not run.
88    ///
89    /// `None` and `Some(vec![])` are not interchangeable: the former means the
90    /// column was never scanned, the latter that it was scanned and nothing
91    /// matched. Consumers that gate on sensitivity -- redaction, agent-facing
92    /// output -- must treat `None` as "unknown", never as "no sensitive data".
93    pub patterns: Option<Vec<Pattern>>,
94}
95
96/// Quartile statistics for numeric distributions.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
98pub struct Quartiles {
99    pub q1: f64,
100    pub q2: f64,
101    pub q3: f64,
102    /// `q3 - q1`, or `None` when that difference exceeds the finite `f64`
103    /// range; the quartiles themselves are still reported. Equal quartiles
104    /// give `Some(0.0)`, so `None` never means "no spread".
105    #[serde(deserialize_with = "crate::serde_helpers::required_nullable")]
106    pub iqr: Option<f64>,
107}
108
109/// A value and its frequency count within a column.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
111pub struct FrequencyItem {
112    pub value: String,
113    pub count: usize,
114    #[serde(serialize_with = "crate::serde_helpers::round_2")]
115    pub percentage: f64,
116}
117
118/// The estimator behind `skewness` and `kurtosis`, as recorded in a report's
119/// metric semantics.
120///
121/// Only the current estimator has a value. Through 0.12 both divided
122/// population moments by the sample standard deviation, a form matching
123/// neither the population nor the adjusted estimators, and those releases
124/// recorded no definition.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
126#[serde(rename_all = "snake_case")]
127pub enum ShapeMomentEstimator {
128    /// Adjusted Fisher-Pearson sample skewness from three values and adjusted
129    /// Fisher sample excess kurtosis from four, as pandas computes them.
130    AdjustedSample,
131}
132
133/// Statistics for numeric (integer or float) columns.
134#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
135pub struct NumericStats {
136    // min/max/median/mode are data values, not summary percentages: they are
137    // rounded at the statistics precision so a column whose values carry more
138    // than two decimals is not reported with a min it never contained.
139    #[serde(serialize_with = "crate::serde_helpers::round_4")]
140    pub min: f64,
141    #[serde(serialize_with = "crate::serde_helpers::round_4")]
142    pub max: f64,
143    #[serde(serialize_with = "crate::serde_helpers::round_4")]
144    pub mean: f64,
145    /// `None` when the spread of the values exceeds the finite `f64` range.
146    /// A constant column is `Some(0.0)`, so `None` never means "no spread".
147    #[serde(
148        serialize_with = "crate::serde_helpers::round_4_opt",
149        deserialize_with = "crate::serde_helpers::required_nullable"
150    )]
151    pub std_dev: Option<f64>,
152    /// `None` when the spread of the values exceeds the finite `f64` range.
153    /// A constant column is `Some(0.0)`, so `None` never means "no spread".
154    #[serde(
155        serialize_with = "crate::serde_helpers::round_4_opt",
156        deserialize_with = "crate::serde_helpers::required_nullable"
157    )]
158    pub variance: Option<f64>,
159    #[serde(
160        skip_serializing_if = "Option::is_none",
161        serialize_with = "crate::serde_helpers::round_4_opt"
162    )]
163    pub median: Option<f64>,
164    #[serde(
165        skip_serializing_if = "Option::is_none",
166        serialize_with = "crate::serde_helpers::quartiles::serialize"
167    )]
168    pub quartiles: Option<Quartiles>,
169    #[serde(
170        skip_serializing_if = "Option::is_none",
171        serialize_with = "crate::serde_helpers::round_4_opt"
172    )]
173    pub mode: Option<f64>,
174    #[serde(
175        skip_serializing_if = "Option::is_none",
176        serialize_with = "crate::serde_helpers::round_2_opt"
177    )]
178    pub coefficient_of_variation: Option<f64>,
179    #[serde(
180        skip_serializing_if = "Option::is_none",
181        serialize_with = "crate::serde_helpers::round_4_opt"
182    )]
183    pub skewness: Option<f64>,
184    #[serde(
185        skip_serializing_if = "Option::is_none",
186        serialize_with = "crate::serde_helpers::round_4_opt"
187    )]
188    pub kurtosis: Option<f64>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub is_approximate: Option<bool>,
191    /// Number of values flagged as IQR-based outliers in this column.
192    ///
193    /// Uses the same Tukey-style detection (Q1 − k·IQR, Q3 + k·IQR with
194    /// k = 1.5 by default) that feeds the global `accuracy.outlier_ratio`.
195    /// `None` when outlier detection didn't run (sample below the configured
196    /// minimum or non-numeric column).
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub outlier_count: Option<usize>,
199}
200
201impl NumericStats {
202    pub fn empty() -> Self {
203        Self {
204            min: 0.0,
205            max: 0.0,
206            mean: 0.0,
207            std_dev: Some(0.0),
208            variance: Some(0.0),
209            median: None,
210            quartiles: None,
211            mode: None,
212            coefficient_of_variation: None,
213            skewness: None,
214            kurtosis: None,
215            is_approximate: None,
216            outlier_count: None,
217        }
218    }
219}
220
221/// Statistics for text/string columns.
222///
223/// Every length here is a count of Unicode scalar values, not UTF-8 bytes and
224/// not grapheme clusters. ASCII text is unaffected by that distinction; a
225/// combining sequence counts each scalar, so the decomposed and precomposed
226/// spellings of the same word report different lengths. The `text_units` module
227/// carries the reasoning.
228#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
229pub struct TextStats {
230    /// Shortest value, in Unicode scalar values.
231    pub min_length: usize,
232    /// Longest value, in Unicode scalar values.
233    pub max_length: usize,
234    /// Mean length in Unicode scalar values. A mean, so it rounds at the
235    /// statistics precision rather than the percentage one.
236    #[serde(serialize_with = "crate::serde_helpers::round_4")]
237    pub avg_length: f64,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub most_frequent: Option<Vec<FrequencyItem>>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub least_frequent: Option<Vec<FrequencyItem>>,
242}
243
244impl TextStats {
245    pub fn empty() -> Self {
246        Self {
247            min_length: 0,
248            max_length: 0,
249            avg_length: 0.0,
250            most_frequent: None,
251            least_frequent: None,
252        }
253    }
254
255    pub fn from_lengths(min_length: usize, max_length: usize, avg_length: f64) -> Self {
256        Self {
257            min_length: if min_length == usize::MAX {
258                0
259            } else {
260                min_length
261            },
262            max_length,
263            avg_length,
264            most_frequent: None,
265            least_frequent: None,
266        }
267    }
268}
269
270/// Statistics for date/datetime columns.
271#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
272pub struct DateTimeStats {
273    pub min_datetime: String,
274    pub max_datetime: String,
275    #[serde(serialize_with = "crate::serde_helpers::round_2")]
276    pub duration_days: f64,
277    pub year_distribution: HashMap<i32, usize>,
278    pub month_distribution: HashMap<u32, usize>,
279    pub day_of_week_distribution: HashMap<String, usize>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub hour_distribution: Option<HashMap<u32, usize>>,
282    /// How the column's `NN/NN/YYYY` values were read (#811).
283    ///
284    /// Decided once per column from its own values, so `01/02/2024` is read
285    /// the same way as the `12/31/2024` beside it. `None` when the column
286    /// holds no slash dates, or the report predates the field.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub slash_date_order: Option<SlashDateOrder>,
289}
290
291/// The day/month order a column's slash dates were read in.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
293#[serde(rename_all = "snake_case")]
294pub enum SlashDateOrder {
295    /// Some values only parse day-first (`31/12/2024`) and none only
296    /// month-first.
297    DayFirst,
298    /// Some values only parse month-first (`12/31/2024`) and none only
299    /// day-first.
300    MonthFirst,
301    /// Every value parses both ways, so nothing in the column decides it; the
302    /// values were read day-first.
303    AssumedDayFirst,
304    /// Values that only parse day-first and values that only parse
305    /// month-first are both present. The column contradicts itself: each value
306    /// was read in the order it parses, day-first where both do, and the dates
307    /// cannot all be right.
308    Mixed,
309}
310
311impl DateTimeStats {
312    pub fn empty() -> Self {
313        Self {
314            min_datetime: String::new(),
315            max_datetime: String::new(),
316            duration_days: 0.0,
317            year_distribution: HashMap::new(),
318            month_distribution: HashMap::new(),
319            day_of_week_distribution: HashMap::new(),
320            hour_distribution: None,
321            slash_date_order: None,
322        }
323    }
324}
325
326/// Statistics for boolean columns.
327#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
328pub struct BooleanStats {
329    pub true_count: usize,
330    pub false_count: usize,
331    #[serde(serialize_with = "crate::serde_helpers::round_4")]
332    pub true_ratio: f64,
333}
334
335/// Type-specific statistics for a column, determined by the inferred data type.
336#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
337pub enum ColumnStats {
338    Numeric(NumericStats),
339    Text(TextStats),
340    DateTime(DateTimeStats),
341    Boolean(BooleanStats),
342    None,
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    /// `std_dev`, `variance` and `quartiles.iqr` are nullable but required: a
350    /// `null` is a spread that overflowed `f64`, a missing key a malformed
351    /// document. Serde reads a missing `Option` as `None`, which conflates them.
352    #[test]
353    fn nullable_spread_fields_are_still_required() {
354        let stats = NumericStats {
355            std_dev: None,
356            variance: None,
357            quartiles: Some(Quartiles {
358                q1: 1.0,
359                q2: 2.0,
360                q3: 3.0,
361                iqr: None,
362            }),
363            ..NumericStats::empty()
364        };
365        let document = serde_json::to_value(&stats).unwrap();
366        let restored: NumericStats = serde_json::from_value(document.clone()).unwrap();
367        assert_eq!(serde_json::to_value(restored).unwrap(), document);
368
369        for (parent, key) in [
370            (None, "std_dev"),
371            (None, "variance"),
372            (Some("quartiles"), "iqr"),
373        ] {
374            let mut malformed = document.clone();
375            let object = match parent {
376                Some(parent) => &mut malformed[parent],
377                None => &mut malformed,
378            };
379            object.as_object_mut().unwrap().remove(key).unwrap();
380            let error = serde_json::from_value::<NumericStats>(malformed).unwrap_err();
381            assert!(
382                error
383                    .to_string()
384                    .contains(&format!("missing field `{key}`")),
385                "{key}: {error}"
386            );
387        }
388    }
389
390    #[test]
391    fn test_column_profile_json_roundtrip() {
392        let profile = ColumnProfile {
393            name: "test_col".to_string(),
394            data_type: DataType::Integer,
395            null_count: 2,
396            total_count: 10,
397            unique_count: Some(8),
398            unique_count_is_approximate: Some(false),
399            unique_count_lower_bound: None,
400            invalid_count: Some(0),
401            type_homogeneity: None,
402            locale_number_count: None,
403            stats: ColumnStats::Numeric(NumericStats {
404                min: 1.0,
405                max: 100.0,
406                mean: 50.5,
407                std_dev: Some(28.87),
408                variance: Some(833.25),
409                median: Some(50.0),
410                quartiles: Some(Quartiles {
411                    q1: 25.0,
412                    q2: 50.0,
413                    q3: 75.0,
414                    iqr: Some(50.0),
415                }),
416                mode: Some(42.0),
417                coefficient_of_variation: Some(57.17),
418                skewness: Some(0.0),
419                kurtosis: Some(-1.2),
420                is_approximate: Some(false),
421                outlier_count: Some(0),
422            }),
423            patterns: Some(vec![]),
424        };
425
426        let json = serde_json::to_string(&profile).unwrap();
427        let deserialized: ColumnProfile = serde_json::from_str(&json).unwrap();
428
429        assert_eq!(deserialized.name, "test_col");
430        assert_eq!(deserialized.data_type, DataType::Integer);
431        assert_eq!(deserialized.total_count, 10);
432        assert_eq!(deserialized.null_count, 2);
433        assert_eq!(deserialized.unique_count_is_approximate, Some(false));
434
435        if let ColumnStats::Numeric(n) = &deserialized.stats {
436            assert!((n.min - 1.0).abs() < 0.01);
437            assert!((n.max - 100.0).abs() < 0.01);
438            assert!((n.mean - 50.5).abs() < 0.01);
439            assert!(n.median.is_some());
440            assert!(n.quartiles.is_some());
441        } else {
442            panic!("Expected Numeric stats after roundtrip");
443        }
444    }
445
446    #[test]
447    fn test_text_stats_json_roundtrip() {
448        let profile = ColumnProfile {
449            name: "name".to_string(),
450            data_type: DataType::String,
451            null_count: 0,
452            total_count: 3,
453            unique_count: Some(3),
454            unique_count_is_approximate: Some(false),
455            unique_count_lower_bound: None,
456            invalid_count: None,
457            type_homogeneity: None,
458            locale_number_count: None,
459            stats: ColumnStats::Text(TextStats {
460                min_length: 3,
461                max_length: 7,
462                avg_length: 5.0,
463                most_frequent: None,
464                least_frequent: None,
465            }),
466            patterns: Some(vec![]),
467        };
468
469        let json = serde_json::to_string(&profile).unwrap();
470        let deserialized: ColumnProfile = serde_json::from_str(&json).unwrap();
471
472        assert_eq!(deserialized.data_type, DataType::String);
473        if let ColumnStats::Text(t) = &deserialized.stats {
474            assert_eq!(t.min_length, 3);
475            assert_eq!(t.max_length, 7);
476        } else {
477            panic!("Expected Text stats after roundtrip");
478        }
479    }
480}