1#[derive(
3 Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
4)]
5pub enum DataType {
6 String,
8 Identifier,
10 Integer,
12 Float,
14 Date,
16 Boolean,
18}
19
20#[derive(
28 Debug,
29 Clone,
30 Copy,
31 PartialEq,
32 Eq,
33 Hash,
34 serde::Serialize,
35 serde::Deserialize,
36 schemars::JsonSchema,
37)]
38#[serde(rename_all = "lowercase")]
39pub enum LexicalClass {
40 Numeric,
43 Date,
45 Boolean,
47 Text,
49}
50
51impl std::fmt::Display for LexicalClass {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 Self::Numeric => write!(f, "numeric"),
55 Self::Date => write!(f, "date"),
56 Self::Boolean => write!(f, "boolean"),
57 Self::Text => write!(f, "text"),
58 }
59 }
60}
61
62#[derive(
73 Debug,
74 Clone,
75 Copy,
76 Default,
77 PartialEq,
78 Eq,
79 serde::Serialize,
80 serde::Deserialize,
81 schemars::JsonSchema,
82)]
83pub struct TypeHomogeneity {
84 pub numeric: usize,
85 pub date: usize,
86 pub boolean: usize,
87 pub text: usize,
88}
89
90impl TypeHomogeneity {
91 fn counts(&self) -> [(LexicalClass, usize); 4] {
93 [
94 (LexicalClass::Numeric, self.numeric),
95 (LexicalClass::Date, self.date),
96 (LexicalClass::Boolean, self.boolean),
97 (LexicalClass::Text, self.text),
98 ]
99 }
100
101 pub fn record(&mut self, class: LexicalClass) {
103 match class {
104 LexicalClass::Numeric => self.numeric += 1,
105 LexicalClass::Date => self.date += 1,
106 LexicalClass::Boolean => self.boolean += 1,
107 LexicalClass::Text => self.text += 1,
108 }
109 }
110
111 pub fn classified_count(&self) -> usize {
113 self.numeric + self.date + self.boolean + self.text
114 }
115
116 pub fn dominant(&self) -> Option<(LexicalClass, usize)> {
123 self.counts()
124 .into_iter()
125 .filter(|(_, count)| *count > 0)
126 .fold(None, |best, candidate| match best {
127 Some((_, best_count)) if best_count >= candidate.1 => best,
128 _ => Some(candidate),
129 })
130 }
131
132 pub fn dominant_share(&self) -> Option<f64> {
135 let (_, count) = self.dominant()?;
136 Some(count as f64 / self.classified_count() as f64)
137 }
138
139 pub fn mixture(&self) -> Vec<(LexicalClass, usize, f64)> {
144 let total = self.classified_count();
145 if total == 0 {
146 return Vec::new();
147 }
148 let mut present: Vec<(LexicalClass, usize)> = self
149 .counts()
150 .into_iter()
151 .filter(|(_, count)| *count > 0)
152 .collect();
153 present.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
155 present
156 .into_iter()
157 .map(|(class, count)| (class, count, count as f64 / total as f64))
158 .collect()
159 }
160}
161
162#[derive(
164 Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
165)]
166#[serde(rename_all = "snake_case")]
167pub enum PatternCategory {
168 Contact,
170 Identifier,
172 Network,
174 Geographic,
176 Financial,
178 FilePath,
180 Other,
182}
183
184impl std::fmt::Display for PatternCategory {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 match self {
187 Self::Contact => write!(f, "contact"),
188 Self::Identifier => write!(f, "identifier"),
189 Self::Network => write!(f, "network"),
190 Self::Geographic => write!(f, "geographic"),
191 Self::Financial => write!(f, "financial"),
192 Self::FilePath => write!(f, "file_path"),
193 Self::Other => write!(f, "other"),
194 }
195 }
196}
197
198#[cfg(test)]
199mod type_homogeneity_tests {
200 use super::*;
201
202 fn homogeneity(numeric: usize, date: usize, boolean: usize, text: usize) -> TypeHomogeneity {
203 TypeHomogeneity {
204 numeric,
205 date,
206 boolean,
207 text,
208 }
209 }
210
211 #[test]
212 fn recording_values_counts_them_by_class() {
213 let mut counts = TypeHomogeneity::default();
214 counts.record(LexicalClass::Numeric);
215 counts.record(LexicalClass::Text);
216 counts.record(LexicalClass::Numeric);
217
218 assert_eq!(counts, homogeneity(2, 0, 0, 1));
219 assert_eq!(counts.classified_count(), 3);
220 assert_eq!(counts.dominant(), Some((LexicalClass::Numeric, 2)));
221 }
222
223 #[test]
224 fn nothing_classified_has_no_dominant_class_and_no_share() {
225 let empty = TypeHomogeneity::default();
229
230 assert_eq!(empty.classified_count(), 0);
231 assert_eq!(empty.dominant(), None);
232 assert_eq!(empty.dominant_share(), None);
233 assert!(empty.mixture().is_empty());
234 }
235
236 #[test]
237 fn a_tie_is_held_by_the_earliest_declared_class() {
238 let tied = homogeneity(50, 50, 0, 0);
242
243 assert_eq!(tied.dominant(), Some((LexicalClass::Numeric, 50)));
244 assert_eq!(tied.dominant_share(), Some(0.5));
245 assert_eq!(
246 tied.mixture(),
247 vec![
248 (LexicalClass::Numeric, 50, 0.5),
249 (LexicalClass::Date, 50, 0.5)
250 ]
251 );
252 }
253
254 #[test]
255 fn mixture_reports_every_present_class_largest_first() {
256 let mixed = homogeneity(60, 10, 0, 30);
257
258 assert_eq!(mixed.dominant_share(), Some(0.6));
259 assert_eq!(
260 mixed.mixture(),
261 vec![
262 (LexicalClass::Numeric, 60, 0.6),
263 (LexicalClass::Text, 30, 0.3),
264 (LexicalClass::Date, 10, 0.1),
265 ],
266 "an absent class must not appear with a 0% share"
267 );
268 }
269
270 #[test]
271 fn counts_survive_a_json_round_trip() {
272 let counts = homogeneity(600, 0, 0, 400);
273 let json = serde_json::to_string(&counts).expect("counts should serialize");
274
275 assert_eq!(json, r#"{"numeric":600,"date":0,"boolean":0,"text":400}"#);
276 assert_eq!(
277 serde_json::from_str::<TypeHomogeneity>(&json).expect("counts should deserialize"),
278 counts
279 );
280 }
281
282 #[test]
283 fn lexical_classes_serialize_as_the_names_reports_use() {
284 for (class, name) in [
285 (LexicalClass::Numeric, "numeric"),
286 (LexicalClass::Date, "date"),
287 (LexicalClass::Boolean, "boolean"),
288 (LexicalClass::Text, "text"),
289 ] {
290 assert_eq!(class.to_string(), name);
291 assert_eq!(
292 serde_json::to_string(&class).expect("class should serialize"),
293 format!("\"{name}\"")
294 );
295 }
296 }
297}