#[derive(
Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub enum DataType {
String,
Identifier,
Integer,
Float,
Date,
Boolean,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
serde::Serialize,
serde::Deserialize,
schemars::JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum LexicalClass {
Numeric,
Date,
Boolean,
Text,
}
impl std::fmt::Display for LexicalClass {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Numeric => write!(f, "numeric"),
Self::Date => write!(f, "date"),
Self::Boolean => write!(f, "boolean"),
Self::Text => write!(f, "text"),
}
}
}
#[derive(
Debug,
Clone,
Copy,
Default,
PartialEq,
Eq,
serde::Serialize,
serde::Deserialize,
schemars::JsonSchema,
)]
pub struct TypeHomogeneity {
pub numeric: usize,
pub date: usize,
pub boolean: usize,
pub text: usize,
}
impl TypeHomogeneity {
fn counts(&self) -> [(LexicalClass, usize); 4] {
[
(LexicalClass::Numeric, self.numeric),
(LexicalClass::Date, self.date),
(LexicalClass::Boolean, self.boolean),
(LexicalClass::Text, self.text),
]
}
pub fn record(&mut self, class: LexicalClass) {
match class {
LexicalClass::Numeric => self.numeric += 1,
LexicalClass::Date => self.date += 1,
LexicalClass::Boolean => self.boolean += 1,
LexicalClass::Text => self.text += 1,
}
}
pub fn classified_count(&self) -> usize {
self.numeric + self.date + self.boolean + self.text
}
pub fn dominant(&self) -> Option<(LexicalClass, usize)> {
self.counts()
.into_iter()
.filter(|(_, count)| *count > 0)
.fold(None, |best, candidate| match best {
Some((_, best_count)) if best_count >= candidate.1 => best,
_ => Some(candidate),
})
}
pub fn dominant_share(&self) -> Option<f64> {
let (_, count) = self.dominant()?;
Some(count as f64 / self.classified_count() as f64)
}
pub fn mixture(&self) -> Vec<(LexicalClass, usize, f64)> {
let total = self.classified_count();
if total == 0 {
return Vec::new();
}
let mut present: Vec<(LexicalClass, usize)> = self
.counts()
.into_iter()
.filter(|(_, count)| *count > 0)
.collect();
present.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
present
.into_iter()
.map(|(class, count)| (class, count, count as f64 / total as f64))
.collect()
}
}
#[derive(
Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum PatternCategory {
Contact,
Identifier,
Network,
Geographic,
Financial,
FilePath,
Other,
}
impl std::fmt::Display for PatternCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Contact => write!(f, "contact"),
Self::Identifier => write!(f, "identifier"),
Self::Network => write!(f, "network"),
Self::Geographic => write!(f, "geographic"),
Self::Financial => write!(f, "financial"),
Self::FilePath => write!(f, "file_path"),
Self::Other => write!(f, "other"),
}
}
}
#[cfg(test)]
mod type_homogeneity_tests {
use super::*;
fn homogeneity(numeric: usize, date: usize, boolean: usize, text: usize) -> TypeHomogeneity {
TypeHomogeneity {
numeric,
date,
boolean,
text,
}
}
#[test]
fn recording_values_counts_them_by_class() {
let mut counts = TypeHomogeneity::default();
counts.record(LexicalClass::Numeric);
counts.record(LexicalClass::Text);
counts.record(LexicalClass::Numeric);
assert_eq!(counts, homogeneity(2, 0, 0, 1));
assert_eq!(counts.classified_count(), 3);
assert_eq!(counts.dominant(), Some((LexicalClass::Numeric, 2)));
}
#[test]
fn nothing_classified_has_no_dominant_class_and_no_share() {
let empty = TypeHomogeneity::default();
assert_eq!(empty.classified_count(), 0);
assert_eq!(empty.dominant(), None);
assert_eq!(empty.dominant_share(), None);
assert!(empty.mixture().is_empty());
}
#[test]
fn a_tie_is_held_by_the_earliest_declared_class() {
let tied = homogeneity(50, 50, 0, 0);
assert_eq!(tied.dominant(), Some((LexicalClass::Numeric, 50)));
assert_eq!(tied.dominant_share(), Some(0.5));
assert_eq!(
tied.mixture(),
vec![
(LexicalClass::Numeric, 50, 0.5),
(LexicalClass::Date, 50, 0.5)
]
);
}
#[test]
fn mixture_reports_every_present_class_largest_first() {
let mixed = homogeneity(60, 10, 0, 30);
assert_eq!(mixed.dominant_share(), Some(0.6));
assert_eq!(
mixed.mixture(),
vec![
(LexicalClass::Numeric, 60, 0.6),
(LexicalClass::Text, 30, 0.3),
(LexicalClass::Date, 10, 0.1),
],
"an absent class must not appear with a 0% share"
);
}
#[test]
fn counts_survive_a_json_round_trip() {
let counts = homogeneity(600, 0, 0, 400);
let json = serde_json::to_string(&counts).expect("counts should serialize");
assert_eq!(json, r#"{"numeric":600,"date":0,"boolean":0,"text":400}"#);
assert_eq!(
serde_json::from_str::<TypeHomogeneity>(&json).expect("counts should deserialize"),
counts
);
}
#[test]
fn lexical_classes_serialize_as_the_names_reports_use() {
for (class, name) in [
(LexicalClass::Numeric, "numeric"),
(LexicalClass::Date, "date"),
(LexicalClass::Boolean, "boolean"),
(LexicalClass::Text, "text"),
] {
assert_eq!(class.to_string(), name);
assert_eq!(
serde_json::to_string(&class).expect("class should serialize"),
format!("\"{name}\"")
);
}
}
}