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/// Statistics for numeric (integer or float) columns.
119#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
120pub struct NumericStats {
121    // min/max/median/mode are data values, not summary percentages: they are
122    // rounded at the statistics precision so a column whose values carry more
123    // than two decimals is not reported with a min it never contained.
124    #[serde(serialize_with = "crate::serde_helpers::round_4")]
125    pub min: f64,
126    #[serde(serialize_with = "crate::serde_helpers::round_4")]
127    pub max: f64,
128    #[serde(serialize_with = "crate::serde_helpers::round_4")]
129    pub mean: f64,
130    /// `None` when the spread of the values exceeds the finite `f64` range.
131    /// A constant column is `Some(0.0)`, so `None` never means "no spread".
132    #[serde(
133        serialize_with = "crate::serde_helpers::round_4_opt",
134        deserialize_with = "crate::serde_helpers::required_nullable"
135    )]
136    pub std_dev: Option<f64>,
137    /// `None` when the spread of the values exceeds the finite `f64` range.
138    /// A constant column is `Some(0.0)`, so `None` never means "no spread".
139    #[serde(
140        serialize_with = "crate::serde_helpers::round_4_opt",
141        deserialize_with = "crate::serde_helpers::required_nullable"
142    )]
143    pub variance: Option<f64>,
144    #[serde(
145        skip_serializing_if = "Option::is_none",
146        serialize_with = "crate::serde_helpers::round_4_opt"
147    )]
148    pub median: Option<f64>,
149    #[serde(
150        skip_serializing_if = "Option::is_none",
151        serialize_with = "crate::serde_helpers::quartiles::serialize"
152    )]
153    pub quartiles: Option<Quartiles>,
154    #[serde(
155        skip_serializing_if = "Option::is_none",
156        serialize_with = "crate::serde_helpers::round_4_opt"
157    )]
158    pub mode: Option<f64>,
159    #[serde(
160        skip_serializing_if = "Option::is_none",
161        serialize_with = "crate::serde_helpers::round_2_opt"
162    )]
163    pub coefficient_of_variation: Option<f64>,
164    #[serde(
165        skip_serializing_if = "Option::is_none",
166        serialize_with = "crate::serde_helpers::round_4_opt"
167    )]
168    pub skewness: Option<f64>,
169    #[serde(
170        skip_serializing_if = "Option::is_none",
171        serialize_with = "crate::serde_helpers::round_4_opt"
172    )]
173    pub kurtosis: Option<f64>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub is_approximate: Option<bool>,
176    /// Number of values flagged as IQR-based outliers in this column.
177    ///
178    /// Uses the same Tukey-style detection (Q1 − k·IQR, Q3 + k·IQR with
179    /// k = 1.5 by default) that feeds the global `accuracy.outlier_ratio`.
180    /// `None` when outlier detection didn't run (sample below the configured
181    /// minimum or non-numeric column).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub outlier_count: Option<usize>,
184}
185
186impl NumericStats {
187    pub fn empty() -> Self {
188        Self {
189            min: 0.0,
190            max: 0.0,
191            mean: 0.0,
192            std_dev: Some(0.0),
193            variance: Some(0.0),
194            median: None,
195            quartiles: None,
196            mode: None,
197            coefficient_of_variation: None,
198            skewness: None,
199            kurtosis: None,
200            is_approximate: None,
201            outlier_count: None,
202        }
203    }
204}
205
206/// Statistics for text/string columns.
207///
208/// Every length here is a count of Unicode scalar values, not UTF-8 bytes and
209/// not grapheme clusters. ASCII text is unaffected by that distinction; a
210/// combining sequence counts each scalar, so the decomposed and precomposed
211/// spellings of the same word report different lengths. The `text_units` module
212/// carries the reasoning.
213#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
214pub struct TextStats {
215    /// Shortest value, in Unicode scalar values.
216    pub min_length: usize,
217    /// Longest value, in Unicode scalar values.
218    pub max_length: usize,
219    /// Mean length in Unicode scalar values. A mean, so it rounds at the
220    /// statistics precision rather than the percentage one.
221    #[serde(serialize_with = "crate::serde_helpers::round_4")]
222    pub avg_length: f64,
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub most_frequent: Option<Vec<FrequencyItem>>,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub least_frequent: Option<Vec<FrequencyItem>>,
227}
228
229impl TextStats {
230    pub fn empty() -> Self {
231        Self {
232            min_length: 0,
233            max_length: 0,
234            avg_length: 0.0,
235            most_frequent: None,
236            least_frequent: None,
237        }
238    }
239
240    pub fn from_lengths(min_length: usize, max_length: usize, avg_length: f64) -> Self {
241        Self {
242            min_length: if min_length == usize::MAX {
243                0
244            } else {
245                min_length
246            },
247            max_length,
248            avg_length,
249            most_frequent: None,
250            least_frequent: None,
251        }
252    }
253}
254
255/// Statistics for date/datetime columns.
256#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
257pub struct DateTimeStats {
258    pub min_datetime: String,
259    pub max_datetime: String,
260    #[serde(serialize_with = "crate::serde_helpers::round_2")]
261    pub duration_days: f64,
262    pub year_distribution: HashMap<i32, usize>,
263    pub month_distribution: HashMap<u32, usize>,
264    pub day_of_week_distribution: HashMap<String, usize>,
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub hour_distribution: Option<HashMap<u32, usize>>,
267    /// How the column's `NN/NN/YYYY` values were read (#811).
268    ///
269    /// Decided once per column from its own values, so `01/02/2024` is read
270    /// the same way as the `12/31/2024` beside it. `None` when the column
271    /// holds no slash dates, or the report predates the field.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub slash_date_order: Option<SlashDateOrder>,
274}
275
276/// The day/month order a column's slash dates were read in.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
278#[serde(rename_all = "snake_case")]
279pub enum SlashDateOrder {
280    /// Some values only parse day-first (`31/12/2024`) and none only
281    /// month-first.
282    DayFirst,
283    /// Some values only parse month-first (`12/31/2024`) and none only
284    /// day-first.
285    MonthFirst,
286    /// Every value parses both ways, so nothing in the column decides it; the
287    /// values were read day-first.
288    AssumedDayFirst,
289    /// Values that only parse day-first and values that only parse
290    /// month-first are both present. The column contradicts itself: each value
291    /// was read in the order it parses, day-first where both do, and the dates
292    /// cannot all be right.
293    Mixed,
294}
295
296impl DateTimeStats {
297    pub fn empty() -> Self {
298        Self {
299            min_datetime: String::new(),
300            max_datetime: String::new(),
301            duration_days: 0.0,
302            year_distribution: HashMap::new(),
303            month_distribution: HashMap::new(),
304            day_of_week_distribution: HashMap::new(),
305            hour_distribution: None,
306            slash_date_order: None,
307        }
308    }
309}
310
311/// Statistics for boolean columns.
312#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
313pub struct BooleanStats {
314    pub true_count: usize,
315    pub false_count: usize,
316    #[serde(serialize_with = "crate::serde_helpers::round_4")]
317    pub true_ratio: f64,
318}
319
320/// Type-specific statistics for a column, determined by the inferred data type.
321#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
322pub enum ColumnStats {
323    Numeric(NumericStats),
324    Text(TextStats),
325    DateTime(DateTimeStats),
326    Boolean(BooleanStats),
327    None,
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    /// `std_dev`, `variance` and `quartiles.iqr` are nullable but required: a
335    /// `null` is a spread that overflowed `f64`, a missing key a malformed
336    /// document. Serde reads a missing `Option` as `None`, which conflates them.
337    #[test]
338    fn nullable_spread_fields_are_still_required() {
339        let stats = NumericStats {
340            std_dev: None,
341            variance: None,
342            quartiles: Some(Quartiles {
343                q1: 1.0,
344                q2: 2.0,
345                q3: 3.0,
346                iqr: None,
347            }),
348            ..NumericStats::empty()
349        };
350        let document = serde_json::to_value(&stats).unwrap();
351        let restored: NumericStats = serde_json::from_value(document.clone()).unwrap();
352        assert_eq!(serde_json::to_value(restored).unwrap(), document);
353
354        for (parent, key) in [
355            (None, "std_dev"),
356            (None, "variance"),
357            (Some("quartiles"), "iqr"),
358        ] {
359            let mut malformed = document.clone();
360            let object = match parent {
361                Some(parent) => &mut malformed[parent],
362                None => &mut malformed,
363            };
364            object.as_object_mut().unwrap().remove(key).unwrap();
365            let error = serde_json::from_value::<NumericStats>(malformed).unwrap_err();
366            assert!(
367                error
368                    .to_string()
369                    .contains(&format!("missing field `{key}`")),
370                "{key}: {error}"
371            );
372        }
373    }
374
375    #[test]
376    fn test_column_profile_json_roundtrip() {
377        let profile = ColumnProfile {
378            name: "test_col".to_string(),
379            data_type: DataType::Integer,
380            null_count: 2,
381            total_count: 10,
382            unique_count: Some(8),
383            unique_count_is_approximate: Some(false),
384            unique_count_lower_bound: None,
385            invalid_count: Some(0),
386            type_homogeneity: None,
387            locale_number_count: None,
388            stats: ColumnStats::Numeric(NumericStats {
389                min: 1.0,
390                max: 100.0,
391                mean: 50.5,
392                std_dev: Some(28.87),
393                variance: Some(833.25),
394                median: Some(50.0),
395                quartiles: Some(Quartiles {
396                    q1: 25.0,
397                    q2: 50.0,
398                    q3: 75.0,
399                    iqr: Some(50.0),
400                }),
401                mode: Some(42.0),
402                coefficient_of_variation: Some(57.17),
403                skewness: Some(0.0),
404                kurtosis: Some(-1.2),
405                is_approximate: Some(false),
406                outlier_count: Some(0),
407            }),
408            patterns: Some(vec![]),
409        };
410
411        let json = serde_json::to_string(&profile).unwrap();
412        let deserialized: ColumnProfile = serde_json::from_str(&json).unwrap();
413
414        assert_eq!(deserialized.name, "test_col");
415        assert_eq!(deserialized.data_type, DataType::Integer);
416        assert_eq!(deserialized.total_count, 10);
417        assert_eq!(deserialized.null_count, 2);
418        assert_eq!(deserialized.unique_count_is_approximate, Some(false));
419
420        if let ColumnStats::Numeric(n) = &deserialized.stats {
421            assert!((n.min - 1.0).abs() < 0.01);
422            assert!((n.max - 100.0).abs() < 0.01);
423            assert!((n.mean - 50.5).abs() < 0.01);
424            assert!(n.median.is_some());
425            assert!(n.quartiles.is_some());
426        } else {
427            panic!("Expected Numeric stats after roundtrip");
428        }
429    }
430
431    #[test]
432    fn test_text_stats_json_roundtrip() {
433        let profile = ColumnProfile {
434            name: "name".to_string(),
435            data_type: DataType::String,
436            null_count: 0,
437            total_count: 3,
438            unique_count: Some(3),
439            unique_count_is_approximate: Some(false),
440            unique_count_lower_bound: None,
441            invalid_count: None,
442            type_homogeneity: None,
443            locale_number_count: None,
444            stats: ColumnStats::Text(TextStats {
445                min_length: 3,
446                max_length: 7,
447                avg_length: 5.0,
448                most_frequent: None,
449                least_frequent: None,
450            }),
451            patterns: Some(vec![]),
452        };
453
454        let json = serde_json::to_string(&profile).unwrap();
455        let deserialized: ColumnProfile = serde_json::from_str(&json).unwrap();
456
457        assert_eq!(deserialized.data_type, DataType::String);
458        if let ColumnStats::Text(t) = &deserialized.stats {
459            assert_eq!(t.min_length, 3);
460            assert_eq!(t.max_length, 7);
461        } else {
462            panic!("Expected Text stats after roundtrip");
463        }
464    }
465}