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    /// Non-null values that failed the column type's raw validity predicate:
28    /// non-finite or malformed numbers on numeric columns, and values that do
29    /// not parse directly as calendar dates on date columns. The date predicate
30    /// intentionally does not trim surrounding whitespace; descriptive date
31    /// statistics may normalize whitespace independently.
32    ///
33    /// For numeric columns, `mean`/`std_dev` cover
34    /// `total_count - null_count - invalid_count` values. For date columns,
35    /// this count audits the strict raw-value quality predicate. `None` means
36    /// the check did not run (another column type, or statistics skipped) —
37    /// never "no invalid values", which is `Some(0)`.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub invalid_count: Option<usize>,
40    /// How the column's non-null values distribute across lexical classes.
41    ///
42    /// `data_type` cannot answer "did this column have a dominant form?": a
43    /// column of names and a column that is 60% numbers are both `String`, and
44    /// `invalid_count` is absent on string columns by contract. This carries the
45    /// evidence, so a consumer can tell a textual column from one that defeated
46    /// type inference.
47    ///
48    /// Counted over the values the profiler retained — the engine's bounded
49    /// reservoir sample on a large source, the whole column on a small or
50    /// in-memory one. `classified_count()` against `total_count - null_count`
51    /// is what says which happened; treat the shares as sampled whenever it is
52    /// short.
53    ///
54    /// `None` means the classification did not run, never "one uniform class":
55    /// a column that was classified and had nothing to classify (all-null, or
56    /// zero rows) is `Some` with every count zero.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub type_homogeneity: Option<TypeHomogeneity>,
59    pub stats: ColumnStats,
60    /// Detected patterns, or `None` when pattern detection did not run.
61    ///
62    /// `None` and `Some(vec![])` are not interchangeable: the former means the
63    /// column was never scanned, the latter that it was scanned and nothing
64    /// matched. Consumers that gate on sensitivity -- redaction, agent-facing
65    /// output -- must treat `None` as "unknown", never as "no sensitive data".
66    pub patterns: Option<Vec<Pattern>>,
67}
68
69/// Quartile statistics for numeric distributions.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
71pub struct Quartiles {
72    pub q1: f64,
73    pub q2: f64,
74    pub q3: f64,
75    pub iqr: f64,
76}
77
78/// A value and its frequency count within a column.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
80pub struct FrequencyItem {
81    pub value: String,
82    pub count: usize,
83    #[serde(serialize_with = "crate::serde_helpers::round_2")]
84    pub percentage: f64,
85}
86
87/// Statistics for numeric (integer or float) columns.
88#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
89pub struct NumericStats {
90    // min/max/median/mode are data values, not summary percentages: they are
91    // rounded at the statistics precision so a column whose values carry more
92    // than two decimals is not reported with a min it never contained.
93    #[serde(serialize_with = "crate::serde_helpers::round_4")]
94    pub min: f64,
95    #[serde(serialize_with = "crate::serde_helpers::round_4")]
96    pub max: f64,
97    #[serde(serialize_with = "crate::serde_helpers::round_4")]
98    pub mean: f64,
99    #[serde(serialize_with = "crate::serde_helpers::round_4")]
100    pub std_dev: f64,
101    #[serde(serialize_with = "crate::serde_helpers::round_4")]
102    pub variance: f64,
103    #[serde(
104        skip_serializing_if = "Option::is_none",
105        serialize_with = "crate::serde_helpers::round_4_opt"
106    )]
107    pub median: Option<f64>,
108    #[serde(
109        skip_serializing_if = "Option::is_none",
110        serialize_with = "crate::serde_helpers::quartiles::serialize"
111    )]
112    pub quartiles: Option<Quartiles>,
113    #[serde(
114        skip_serializing_if = "Option::is_none",
115        serialize_with = "crate::serde_helpers::round_4_opt"
116    )]
117    pub mode: Option<f64>,
118    #[serde(
119        skip_serializing_if = "Option::is_none",
120        serialize_with = "crate::serde_helpers::round_2_opt"
121    )]
122    pub coefficient_of_variation: Option<f64>,
123    #[serde(
124        skip_serializing_if = "Option::is_none",
125        serialize_with = "crate::serde_helpers::round_4_opt"
126    )]
127    pub skewness: Option<f64>,
128    #[serde(
129        skip_serializing_if = "Option::is_none",
130        serialize_with = "crate::serde_helpers::round_4_opt"
131    )]
132    pub kurtosis: Option<f64>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub is_approximate: Option<bool>,
135    /// Number of values flagged as IQR-based outliers in this column.
136    ///
137    /// Uses the same Tukey-style detection (Q1 − k·IQR, Q3 + k·IQR with
138    /// k = 1.5 by default) that feeds the global `accuracy.outlier_ratio`.
139    /// `None` when outlier detection didn't run (sample below the configured
140    /// minimum or non-numeric column).
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub outlier_count: Option<usize>,
143}
144
145impl NumericStats {
146    pub fn empty() -> Self {
147        Self {
148            min: 0.0,
149            max: 0.0,
150            mean: 0.0,
151            std_dev: 0.0,
152            variance: 0.0,
153            median: None,
154            quartiles: None,
155            mode: None,
156            coefficient_of_variation: None,
157            skewness: None,
158            kurtosis: None,
159            is_approximate: None,
160            outlier_count: None,
161        }
162    }
163}
164
165/// Statistics for text/string columns.
166#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
167pub struct TextStats {
168    pub min_length: usize,
169    pub max_length: usize,
170    // A mean, so it rounds at the statistics precision rather than the
171    // percentage one.
172    #[serde(serialize_with = "crate::serde_helpers::round_4")]
173    pub avg_length: f64,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub most_frequent: Option<Vec<FrequencyItem>>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub least_frequent: Option<Vec<FrequencyItem>>,
178}
179
180impl TextStats {
181    pub fn empty() -> Self {
182        Self {
183            min_length: 0,
184            max_length: 0,
185            avg_length: 0.0,
186            most_frequent: None,
187            least_frequent: None,
188        }
189    }
190
191    pub fn from_lengths(min_length: usize, max_length: usize, avg_length: f64) -> Self {
192        Self {
193            min_length: if min_length == usize::MAX {
194                0
195            } else {
196                min_length
197            },
198            max_length,
199            avg_length,
200            most_frequent: None,
201            least_frequent: None,
202        }
203    }
204}
205
206/// Statistics for date/datetime columns.
207#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
208pub struct DateTimeStats {
209    pub min_datetime: String,
210    pub max_datetime: String,
211    #[serde(serialize_with = "crate::serde_helpers::round_2")]
212    pub duration_days: f64,
213    pub year_distribution: HashMap<i32, usize>,
214    pub month_distribution: HashMap<u32, usize>,
215    pub day_of_week_distribution: HashMap<String, usize>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub hour_distribution: Option<HashMap<u32, usize>>,
218}
219
220impl DateTimeStats {
221    pub fn empty() -> Self {
222        Self {
223            min_datetime: String::new(),
224            max_datetime: String::new(),
225            duration_days: 0.0,
226            year_distribution: HashMap::new(),
227            month_distribution: HashMap::new(),
228            day_of_week_distribution: HashMap::new(),
229            hour_distribution: None,
230        }
231    }
232}
233
234/// Statistics for boolean columns.
235#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
236pub struct BooleanStats {
237    pub true_count: usize,
238    pub false_count: usize,
239    #[serde(serialize_with = "crate::serde_helpers::round_4")]
240    pub true_ratio: f64,
241}
242
243/// Type-specific statistics for a column, determined by the inferred data type.
244#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
245pub enum ColumnStats {
246    Numeric(NumericStats),
247    Text(TextStats),
248    DateTime(DateTimeStats),
249    Boolean(BooleanStats),
250    None,
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_column_profile_json_roundtrip() {
259        let profile = ColumnProfile {
260            name: "test_col".to_string(),
261            data_type: DataType::Integer,
262            null_count: 2,
263            total_count: 10,
264            unique_count: Some(8),
265            unique_count_is_approximate: Some(false),
266            invalid_count: Some(0),
267            type_homogeneity: None,
268            stats: ColumnStats::Numeric(NumericStats {
269                min: 1.0,
270                max: 100.0,
271                mean: 50.5,
272                std_dev: 28.87,
273                variance: 833.25,
274                median: Some(50.0),
275                quartiles: Some(Quartiles {
276                    q1: 25.0,
277                    q2: 50.0,
278                    q3: 75.0,
279                    iqr: 50.0,
280                }),
281                mode: Some(42.0),
282                coefficient_of_variation: Some(57.17),
283                skewness: Some(0.0),
284                kurtosis: Some(-1.2),
285                is_approximate: Some(false),
286                outlier_count: Some(0),
287            }),
288            patterns: Some(vec![]),
289        };
290
291        let json = serde_json::to_string(&profile).unwrap();
292        let deserialized: ColumnProfile = serde_json::from_str(&json).unwrap();
293
294        assert_eq!(deserialized.name, "test_col");
295        assert_eq!(deserialized.data_type, DataType::Integer);
296        assert_eq!(deserialized.total_count, 10);
297        assert_eq!(deserialized.null_count, 2);
298        assert_eq!(deserialized.unique_count_is_approximate, Some(false));
299
300        if let ColumnStats::Numeric(n) = &deserialized.stats {
301            assert!((n.min - 1.0).abs() < 0.01);
302            assert!((n.max - 100.0).abs() < 0.01);
303            assert!((n.mean - 50.5).abs() < 0.01);
304            assert!(n.median.is_some());
305            assert!(n.quartiles.is_some());
306        } else {
307            panic!("Expected Numeric stats after roundtrip");
308        }
309    }
310
311    #[test]
312    fn test_text_stats_json_roundtrip() {
313        let profile = ColumnProfile {
314            name: "name".to_string(),
315            data_type: DataType::String,
316            null_count: 0,
317            total_count: 3,
318            unique_count: Some(3),
319            unique_count_is_approximate: Some(false),
320            invalid_count: None,
321            type_homogeneity: None,
322            stats: ColumnStats::Text(TextStats {
323                min_length: 3,
324                max_length: 7,
325                avg_length: 5.0,
326                most_frequent: None,
327                least_frequent: None,
328            }),
329            patterns: Some(vec![]),
330        };
331
332        let json = serde_json::to_string(&profile).unwrap();
333        let deserialized: ColumnProfile = serde_json::from_str(&json).unwrap();
334
335        assert_eq!(deserialized.data_type, DataType::String);
336        if let ColumnStats::Text(t) = &deserialized.stats {
337            assert_eq!(t.min_length, 3);
338            assert_eq!(t.max_length, 7);
339        } else {
340            panic!("Expected Text stats after roundtrip");
341        }
342    }
343}