Skip to main content

dataprof_core/
classification.rs

1/// Inferred column data type.
2#[derive(
3    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
4)]
5pub enum DataType {
6    /// Text or string values.
7    String,
8    /// Identifier values that should be treated as semantic strings.
9    Identifier,
10    /// Whole numbers in the i64 range.
11    Integer,
12    /// Floating-point numbers.
13    Float,
14    /// Date or datetime values.
15    Date,
16    /// Boolean values.
17    Boolean,
18    /// Container values: structs, lists and maps.
19    ///
20    /// Only the counts are analyzed. Every other measurement of a container
21    /// would describe how it was serialised rather than the data, and the
22    /// serialisation differs by input path, so those fields are `None` (#637).
23    Nested,
24}
25
26impl DataType {
27    /// The lowercase name that flat summaries, the Python bindings and the quality
28    /// gate's diagnostics show, such as `"integer"`. Serde keeps the variant name;
29    /// this is the one other spelling, kept in one place so a new variant gets it.
30    pub fn name(&self) -> &'static str {
31        match self {
32            Self::String => "string",
33            Self::Identifier => "identifier",
34            Self::Integer => "integer",
35            Self::Float => "float",
36            Self::Date => "date",
37            Self::Boolean => "boolean",
38            Self::Nested => "nested",
39        }
40    }
41
42    /// The type `name()` spells as `name`, or `None` for a name this build does
43    /// not know.
44    pub fn from_name(name: &str) -> Option<Self> {
45        Some(match name {
46            "string" => Self::String,
47            "identifier" => Self::Identifier,
48            "integer" => Self::Integer,
49            "float" => Self::Float,
50            "date" => Self::Date,
51            "boolean" => Self::Boolean,
52            "nested" => Self::Nested,
53            _ => return None,
54        })
55    }
56}
57
58/// Mutually exclusive lexical form of a single non-null value.
59///
60/// The variants partition non-null values — every value belongs to exactly one —
61/// which is what lets the share held by the largest class describe a column
62/// whose inferred [`DataType`] says nothing about the mixture inside it. A
63/// `String` column of names and a `String` column that is 60% numbers are the
64/// same type; they are not the same class distribution.
65#[derive(
66    Debug,
67    Clone,
68    Copy,
69    PartialEq,
70    Eq,
71    Hash,
72    serde::Serialize,
73    serde::Deserialize,
74    schemars::JsonSchema,
75)]
76#[serde(rename_all = "lowercase")]
77pub enum LexicalClass {
78    /// Whole numbers and fractions alike: `["1.5", "2"]` is one numeric column,
79    /// not a two-class mixture.
80    Numeric,
81    /// Any date or datetime form the profiler recognizes.
82    Date,
83    /// Strict boolean tokens.
84    Boolean,
85    /// Everything else.
86    Text,
87}
88
89impl std::fmt::Display for LexicalClass {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            Self::Numeric => write!(f, "numeric"),
93            Self::Date => write!(f, "date"),
94            Self::Boolean => write!(f, "boolean"),
95            Self::Text => write!(f, "text"),
96        }
97    }
98}
99
100/// How a column's non-null values distribute across [`LexicalClass`]es.
101///
102/// Raw counts rather than a share, so that a reader can state the mixture it
103/// actually found ("60% numeric, 40% text") instead of the dominant class and
104/// an unnamed remainder, and so a serialized report carries no rounded
105/// derivative of a number it does not also carry exactly.
106///
107/// All four counts are always present. Every field zero means the column was
108/// classified and had no non-null values to classify — "analyzed, found
109/// nothing", which is not the same as an absent `type_homogeneity`.
110#[derive(
111    Debug,
112    Clone,
113    Copy,
114    Default,
115    PartialEq,
116    Eq,
117    serde::Serialize,
118    serde::Deserialize,
119    schemars::JsonSchema,
120)]
121pub struct TypeHomogeneity {
122    pub numeric: usize,
123    pub date: usize,
124    pub boolean: usize,
125    pub text: usize,
126}
127
128impl TypeHomogeneity {
129    /// Counts in declaration order, which is also the order ties resolve in.
130    fn counts(&self) -> [(LexicalClass, usize); 4] {
131        [
132            (LexicalClass::Numeric, self.numeric),
133            (LexicalClass::Date, self.date),
134            (LexicalClass::Boolean, self.boolean),
135            (LexicalClass::Text, self.text),
136        ]
137    }
138
139    /// Add one value of `class`.
140    pub fn record(&mut self, class: LexicalClass) {
141        match class {
142            LexicalClass::Numeric => self.numeric += 1,
143            LexicalClass::Date => self.date += 1,
144            LexicalClass::Boolean => self.boolean += 1,
145            LexicalClass::Text => self.text += 1,
146        }
147    }
148
149    /// Non-null values classified — the denominator of every share below.
150    pub fn classified_count(&self) -> usize {
151        self.numeric + self.date + self.boolean + self.text
152    }
153
154    /// The class holding the most values and its count, or `None` when nothing
155    /// was classified.
156    ///
157    /// A tie is held by the earliest-declared class. Which class wins does not
158    /// change the reported share — both hold the same count — it only keeps the
159    /// answer stable across runs.
160    pub fn dominant(&self) -> Option<(LexicalClass, usize)> {
161        self.counts()
162            .into_iter()
163            .filter(|(_, count)| *count > 0)
164            .fold(None, |best, candidate| match best {
165                Some((_, best_count)) if best_count >= candidate.1 => best,
166                _ => Some(candidate),
167            })
168    }
169
170    /// Share of classified values held by [`Self::dominant`], in `0.0..=1.0`.
171    /// `None` when nothing was classified, where the ratio is undefined.
172    pub fn dominant_share(&self) -> Option<f64> {
173        let (_, count) = self.dominant()?;
174        Some(count as f64 / self.classified_count() as f64)
175    }
176
177    /// Every class that holds at least one value, largest first, with its share
178    /// of the classified values. Empty when nothing was classified.
179    ///
180    /// Ties keep declaration order, so the sequence is stable across runs.
181    pub fn mixture(&self) -> Vec<(LexicalClass, usize, f64)> {
182        let total = self.classified_count();
183        if total == 0 {
184            return Vec::new();
185        }
186        let mut present: Vec<(LexicalClass, usize)> = self
187            .counts()
188            .into_iter()
189            .filter(|(_, count)| *count > 0)
190            .collect();
191        // Stable sort on the descending count keeps declaration order for ties.
192        present.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
193        present
194            .into_iter()
195            .map(|(class, count)| (class, count, count as f64 / total as f64))
196            .collect()
197    }
198}
199
200/// Semantic category for a detected pattern.
201#[derive(
202    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
203)]
204#[serde(rename_all = "snake_case")]
205pub enum PatternCategory {
206    /// Email addresses, phone numbers.
207    Contact,
208    /// UUIDs, fiscal codes, tax IDs.
209    Identifier,
210    /// IPv4, IPv6, MAC addresses, URLs.
211    Network,
212    /// Coordinates and postal codes.
213    Geographic,
214    /// IBANs, credit cards, SWIFT/BIC.
215    Financial,
216    /// Unix or Windows file paths.
217    FilePath,
218    /// Uncategorized patterns.
219    Other,
220}
221
222impl std::fmt::Display for PatternCategory {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        match self {
225            Self::Contact => write!(f, "contact"),
226            Self::Identifier => write!(f, "identifier"),
227            Self::Network => write!(f, "network"),
228            Self::Geographic => write!(f, "geographic"),
229            Self::Financial => write!(f, "financial"),
230            Self::FilePath => write!(f, "file_path"),
231            Self::Other => write!(f, "other"),
232        }
233    }
234}
235
236#[cfg(test)]
237mod type_homogeneity_tests {
238    use super::*;
239
240    fn homogeneity(numeric: usize, date: usize, boolean: usize, text: usize) -> TypeHomogeneity {
241        TypeHomogeneity {
242            numeric,
243            date,
244            boolean,
245            text,
246        }
247    }
248
249    #[test]
250    fn recording_values_counts_them_by_class() {
251        let mut counts = TypeHomogeneity::default();
252        counts.record(LexicalClass::Numeric);
253        counts.record(LexicalClass::Text);
254        counts.record(LexicalClass::Numeric);
255
256        assert_eq!(counts, homogeneity(2, 0, 0, 1));
257        assert_eq!(counts.classified_count(), 3);
258        assert_eq!(counts.dominant(), Some((LexicalClass::Numeric, 2)));
259    }
260
261    #[test]
262    fn nothing_classified_has_no_dominant_class_and_no_share() {
263        // The all-zero value is "classified, nothing to classify" — every
264        // derived answer is absent rather than a number invented from a zero
265        // denominator.
266        let empty = TypeHomogeneity::default();
267
268        assert_eq!(empty.classified_count(), 0);
269        assert_eq!(empty.dominant(), None);
270        assert_eq!(empty.dominant_share(), None);
271        assert!(empty.mixture().is_empty());
272    }
273
274    #[test]
275    fn a_tie_is_held_by_the_earliest_declared_class() {
276        // Which class wins a tie does not change the share — both hold the same
277        // count — but it must be the same class on every run, and the same one
278        // the consistency dimension scores against.
279        let tied = homogeneity(50, 50, 0, 0);
280
281        assert_eq!(tied.dominant(), Some((LexicalClass::Numeric, 50)));
282        assert_eq!(tied.dominant_share(), Some(0.5));
283        assert_eq!(
284            tied.mixture(),
285            vec![
286                (LexicalClass::Numeric, 50, 0.5),
287                (LexicalClass::Date, 50, 0.5)
288            ]
289        );
290    }
291
292    #[test]
293    fn mixture_reports_every_present_class_largest_first() {
294        let mixed = homogeneity(60, 10, 0, 30);
295
296        assert_eq!(mixed.dominant_share(), Some(0.6));
297        assert_eq!(
298            mixed.mixture(),
299            vec![
300                (LexicalClass::Numeric, 60, 0.6),
301                (LexicalClass::Text, 30, 0.3),
302                (LexicalClass::Date, 10, 0.1),
303            ],
304            "an absent class must not appear with a 0% share"
305        );
306    }
307
308    #[test]
309    fn counts_survive_a_json_round_trip() {
310        let counts = homogeneity(600, 0, 0, 400);
311        let json = serde_json::to_string(&counts).expect("counts should serialize");
312
313        assert_eq!(json, r#"{"numeric":600,"date":0,"boolean":0,"text":400}"#);
314        assert_eq!(
315            serde_json::from_str::<TypeHomogeneity>(&json).expect("counts should deserialize"),
316            counts
317        );
318    }
319
320    #[test]
321    fn lexical_classes_serialize_as_the_names_reports_use() {
322        for (class, name) in [
323            (LexicalClass::Numeric, "numeric"),
324            (LexicalClass::Date, "date"),
325            (LexicalClass::Boolean, "boolean"),
326            (LexicalClass::Text, "text"),
327        ] {
328            assert_eq!(class.to_string(), name);
329            assert_eq!(
330                serde_json::to_string(&class).expect("class should serialize"),
331                format!("\"{name}\"")
332            );
333        }
334    }
335}
336
337#[cfg(test)]
338mod data_type_name_tests {
339    use super::*;
340
341    // The one list a new variant has to join: it pins the spelling and checks
342    // that from_name reads it back.
343    #[test]
344    fn every_data_type_has_its_lowercase_name() {
345        for (data_type, name) in [
346            (DataType::String, "string"),
347            (DataType::Identifier, "identifier"),
348            (DataType::Integer, "integer"),
349            (DataType::Float, "float"),
350            (DataType::Date, "date"),
351            (DataType::Boolean, "boolean"),
352            (DataType::Nested, "nested"),
353        ] {
354            assert_eq!(data_type.name(), name);
355            assert_eq!(DataType::from_name(data_type.name()), Some(data_type));
356        }
357    }
358
359    #[test]
360    fn an_unknown_name_has_no_data_type() {
361        assert_eq!(DataType::from_name("decimal"), None);
362        assert_eq!(DataType::from_name("Integer"), None);
363    }
364}