Skip to main content

dataprof_core/
profile.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::classification::DataType;
6use crate::pattern::Pattern;
7
8/// Profiling statistics for a single column.
9#[derive(Debug, Clone, Serialize, Deserialize)]
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    pub stats: ColumnStats,
41    /// Detected patterns, or `None` when pattern detection did not run.
42    ///
43    /// `None` and `Some(vec![])` are not interchangeable: the former means the
44    /// column was never scanned, the latter that it was scanned and nothing
45    /// matched. Consumers that gate on sensitivity -- redaction, agent-facing
46    /// output -- must treat `None` as "unknown", never as "no sensitive data".
47    pub patterns: Option<Vec<Pattern>>,
48}
49
50/// Quartile statistics for numeric distributions.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct Quartiles {
53    pub q1: f64,
54    pub q2: f64,
55    pub q3: f64,
56    pub iqr: f64,
57}
58
59/// A value and its frequency count within a column.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct FrequencyItem {
62    pub value: String,
63    pub count: usize,
64    #[serde(serialize_with = "crate::serde_helpers::round_2")]
65    pub percentage: f64,
66}
67
68/// Statistics for numeric (integer or float) columns.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct NumericStats {
71    #[serde(serialize_with = "crate::serde_helpers::round_2")]
72    pub min: f64,
73    #[serde(serialize_with = "crate::serde_helpers::round_2")]
74    pub max: f64,
75    #[serde(serialize_with = "crate::serde_helpers::round_4")]
76    pub mean: f64,
77    #[serde(serialize_with = "crate::serde_helpers::round_4")]
78    pub std_dev: f64,
79    #[serde(serialize_with = "crate::serde_helpers::round_4")]
80    pub variance: f64,
81    #[serde(
82        skip_serializing_if = "Option::is_none",
83        serialize_with = "crate::serde_helpers::round_2_opt"
84    )]
85    pub median: Option<f64>,
86    #[serde(
87        skip_serializing_if = "Option::is_none",
88        serialize_with = "crate::serde_helpers::quartiles::serialize"
89    )]
90    pub quartiles: Option<Quartiles>,
91    #[serde(
92        skip_serializing_if = "Option::is_none",
93        serialize_with = "crate::serde_helpers::round_2_opt"
94    )]
95    pub mode: Option<f64>,
96    #[serde(
97        skip_serializing_if = "Option::is_none",
98        serialize_with = "crate::serde_helpers::round_2_opt"
99    )]
100    pub coefficient_of_variation: Option<f64>,
101    #[serde(
102        skip_serializing_if = "Option::is_none",
103        serialize_with = "crate::serde_helpers::round_4_opt"
104    )]
105    pub skewness: Option<f64>,
106    #[serde(
107        skip_serializing_if = "Option::is_none",
108        serialize_with = "crate::serde_helpers::round_4_opt"
109    )]
110    pub kurtosis: Option<f64>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub is_approximate: Option<bool>,
113    /// Number of values flagged as IQR-based outliers in this column.
114    ///
115    /// Uses the same Tukey-style detection (Q1 − k·IQR, Q3 + k·IQR with
116    /// k = 1.5 by default) that feeds the global `accuracy.outlier_ratio`.
117    /// `None` when outlier detection didn't run (sample below the configured
118    /// minimum or non-numeric column).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub outlier_count: Option<usize>,
121}
122
123impl NumericStats {
124    pub fn empty() -> Self {
125        Self {
126            min: 0.0,
127            max: 0.0,
128            mean: 0.0,
129            std_dev: 0.0,
130            variance: 0.0,
131            median: None,
132            quartiles: None,
133            mode: None,
134            coefficient_of_variation: None,
135            skewness: None,
136            kurtosis: None,
137            is_approximate: None,
138            outlier_count: None,
139        }
140    }
141}
142
143/// Statistics for text/string columns.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct TextStats {
146    pub min_length: usize,
147    pub max_length: usize,
148    #[serde(serialize_with = "crate::serde_helpers::round_2")]
149    pub avg_length: f64,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub most_frequent: Option<Vec<FrequencyItem>>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub least_frequent: Option<Vec<FrequencyItem>>,
154}
155
156impl TextStats {
157    pub fn empty() -> Self {
158        Self {
159            min_length: 0,
160            max_length: 0,
161            avg_length: 0.0,
162            most_frequent: None,
163            least_frequent: None,
164        }
165    }
166
167    pub fn from_lengths(min_length: usize, max_length: usize, avg_length: f64) -> Self {
168        Self {
169            min_length: if min_length == usize::MAX {
170                0
171            } else {
172                min_length
173            },
174            max_length,
175            avg_length,
176            most_frequent: None,
177            least_frequent: None,
178        }
179    }
180}
181
182/// Statistics for date/datetime columns.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct DateTimeStats {
185    pub min_datetime: String,
186    pub max_datetime: String,
187    #[serde(serialize_with = "crate::serde_helpers::round_2")]
188    pub duration_days: f64,
189    pub year_distribution: HashMap<i32, usize>,
190    pub month_distribution: HashMap<u32, usize>,
191    pub day_of_week_distribution: HashMap<String, usize>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub hour_distribution: Option<HashMap<u32, usize>>,
194}
195
196impl DateTimeStats {
197    pub fn empty() -> Self {
198        Self {
199            min_datetime: String::new(),
200            max_datetime: String::new(),
201            duration_days: 0.0,
202            year_distribution: HashMap::new(),
203            month_distribution: HashMap::new(),
204            day_of_week_distribution: HashMap::new(),
205            hour_distribution: None,
206        }
207    }
208}
209
210/// Statistics for boolean columns.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct BooleanStats {
213    pub true_count: usize,
214    pub false_count: usize,
215    #[serde(serialize_with = "crate::serde_helpers::round_4")]
216    pub true_ratio: f64,
217}
218
219/// Type-specific statistics for a column, determined by the inferred data type.
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub enum ColumnStats {
222    Numeric(NumericStats),
223    Text(TextStats),
224    DateTime(DateTimeStats),
225    Boolean(BooleanStats),
226    None,
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_column_profile_json_roundtrip() {
235        let profile = ColumnProfile {
236            name: "test_col".to_string(),
237            data_type: DataType::Integer,
238            null_count: 2,
239            total_count: 10,
240            unique_count: Some(8),
241            unique_count_is_approximate: Some(false),
242            invalid_count: Some(0),
243            stats: ColumnStats::Numeric(NumericStats {
244                min: 1.0,
245                max: 100.0,
246                mean: 50.5,
247                std_dev: 28.87,
248                variance: 833.25,
249                median: Some(50.0),
250                quartiles: Some(Quartiles {
251                    q1: 25.0,
252                    q2: 50.0,
253                    q3: 75.0,
254                    iqr: 50.0,
255                }),
256                mode: Some(42.0),
257                coefficient_of_variation: Some(57.17),
258                skewness: Some(0.0),
259                kurtosis: Some(-1.2),
260                is_approximate: Some(false),
261                outlier_count: Some(0),
262            }),
263            patterns: Some(vec![]),
264        };
265
266        let json = serde_json::to_string(&profile).unwrap();
267        let deserialized: ColumnProfile = serde_json::from_str(&json).unwrap();
268
269        assert_eq!(deserialized.name, "test_col");
270        assert_eq!(deserialized.data_type, DataType::Integer);
271        assert_eq!(deserialized.total_count, 10);
272        assert_eq!(deserialized.null_count, 2);
273        assert_eq!(deserialized.unique_count_is_approximate, Some(false));
274
275        if let ColumnStats::Numeric(n) = &deserialized.stats {
276            assert!((n.min - 1.0).abs() < 0.01);
277            assert!((n.max - 100.0).abs() < 0.01);
278            assert!((n.mean - 50.5).abs() < 0.01);
279            assert!(n.median.is_some());
280            assert!(n.quartiles.is_some());
281        } else {
282            panic!("Expected Numeric stats after roundtrip");
283        }
284    }
285
286    #[test]
287    fn test_text_stats_json_roundtrip() {
288        let profile = ColumnProfile {
289            name: "name".to_string(),
290            data_type: DataType::String,
291            null_count: 0,
292            total_count: 3,
293            unique_count: Some(3),
294            unique_count_is_approximate: Some(false),
295            invalid_count: None,
296            stats: ColumnStats::Text(TextStats {
297                min_length: 3,
298                max_length: 7,
299                avg_length: 5.0,
300                most_frequent: None,
301                least_frequent: None,
302            }),
303            patterns: Some(vec![]),
304        };
305
306        let json = serde_json::to_string(&profile).unwrap();
307        let deserialized: ColumnProfile = serde_json::from_str(&json).unwrap();
308
309        assert_eq!(deserialized.data_type, DataType::String);
310        if let ColumnStats::Text(t) = &deserialized.stats {
311            assert_eq!(t.min_length, 3);
312            assert_eq!(t.max_length, 7);
313        } else {
314            panic!("Expected Text stats after roundtrip");
315        }
316    }
317}