use crate::inference::{ClassificationResult, InferenceError, ValueClassifier};
use crate::semantic::SemanticHintClassifier;
use finetype_core::Taxonomy;
use std::collections::HashMap;
const BOOLEAN_LABELS: &[&str] = &[
"representation.boolean.binary", "representation.boolean.initials", "representation.boolean.terms", "technology.development.boolean", "representation.logical.boolean", "technology.data.boolean", ];
#[derive(Debug, Clone)]
pub struct ColumnConfig {
pub sample_size: usize,
pub min_agreement: f32,
}
impl Default for ColumnConfig {
fn default() -> Self {
Self {
sample_size: 100,
min_agreement: 0.3,
}
}
}
#[derive(Debug, Clone)]
pub struct ColumnResult {
pub label: String,
pub confidence: f32,
pub vote_distribution: Vec<(String, f32)>,
pub disambiguation_applied: bool,
pub disambiguation_rule: Option<String>,
pub samples_used: usize,
}
pub struct ColumnClassifier {
classifier: Box<dyn ValueClassifier>,
config: ColumnConfig,
semantic_hint: Option<SemanticHintClassifier>,
taxonomy: Option<Taxonomy>,
}
impl ColumnClassifier {
pub fn new(classifier: Box<dyn ValueClassifier>, config: ColumnConfig) -> Self {
Self {
classifier,
config,
semantic_hint: None,
taxonomy: None,
}
}
pub fn with_defaults(classifier: Box<dyn ValueClassifier>) -> Self {
Self::new(classifier, ColumnConfig::default())
}
pub fn with_semantic_hint(
classifier: Box<dyn ValueClassifier>,
config: ColumnConfig,
semantic: SemanticHintClassifier,
) -> Self {
Self {
classifier,
config,
semantic_hint: Some(semantic),
taxonomy: None,
}
}
pub fn set_semantic_hint(&mut self, semantic: SemanticHintClassifier) {
self.semantic_hint = Some(semantic);
}
pub fn set_taxonomy(&mut self, taxonomy: Taxonomy) {
self.taxonomy = Some(taxonomy);
}
pub fn classify_column(&self, values: &[String]) -> Result<ColumnResult, InferenceError> {
if values.is_empty() {
return Ok(ColumnResult {
label: "unknown".to_string(),
confidence: 0.0,
vote_distribution: vec![],
disambiguation_applied: false,
disambiguation_rule: None,
samples_used: 0,
});
}
let sample = if values.len() <= self.config.sample_size {
values.to_vec()
} else {
let step = values.len() as f64 / self.config.sample_size as f64;
(0..self.config.sample_size)
.map(|i| values[(i as f64 * step) as usize].clone())
.collect()
};
let n_samples = sample.len();
let results = self.classifier.classify_batch(&sample)?;
let mut vote_counts: HashMap<String, usize> = HashMap::new();
for result in &results {
*vote_counts.entry(result.label.clone()).or_default() += 1;
}
let mut votes: Vec<(String, usize)> = vote_counts.into_iter().collect();
votes.sort_by(|a, b| b.1.cmp(&a.1));
let vote_distribution: Vec<(String, f32)> = votes
.iter()
.map(|(label, count)| (label.clone(), *count as f32 / n_samples as f32))
.collect();
let (majority_label, majority_count) = votes.first().cloned().unwrap_or_default();
let majority_fraction = majority_count as f32 / n_samples as f32;
let disambiguation =
disambiguate(&sample, &results, &votes, n_samples, self.taxonomy.as_ref());
if let Some((label, rule_name)) = disambiguation {
let confidence = if rule_name.starts_with("attractor_demotion") {
majority_fraction.max(0.5)
} else {
majority_fraction.max(0.8) };
Ok(ColumnResult {
label,
confidence,
vote_distribution,
disambiguation_applied: true,
disambiguation_rule: Some(rule_name),
samples_used: n_samples,
})
} else {
let confidence = if majority_fraction >= self.config.min_agreement {
majority_fraction
} else {
majority_fraction * 0.5 };
Ok(ColumnResult {
label: majority_label,
confidence,
vote_distribution,
disambiguation_applied: false,
disambiguation_rule: None,
samples_used: n_samples,
})
}
}
pub fn classifier(&self) -> &dyn ValueClassifier {
&*self.classifier
}
pub fn classify_column_with_header(
&self,
values: &[String],
header: &str,
) -> Result<ColumnResult, InferenceError> {
let mut result = self.classify_column(values)?;
let hinted_type: Option<String> = self
.semantic_hint
.as_ref()
.and_then(|sh| sh.classify_header(header))
.map(|r| r.label)
.or_else(|| header_hint(header).map(String::from));
if let Some(hinted_type) = hinted_type.as_deref() {
if result.label == hinted_type {
result.confidence = (result.confidence + 0.1).min(1.0);
return Ok(result);
}
const MEASUREMENT_TYPES: &[&str] = &[
"identity.person.age",
"identity.person.height",
"identity.person.weight",
];
if MEASUREMENT_TYPES.contains(&hinted_type)
&& MEASUREMENT_TYPES.contains(&result.label.as_str())
{
result.label = hinted_type.to_string();
result.confidence = 0.9;
result.disambiguation_applied = true;
result.disambiguation_rule =
Some(format!("header_hint_measurement:{}", header.to_lowercase()));
return Ok(result);
}
let hint_in_votes = result
.vote_distribution
.iter()
.any(|(label, _)| label == hinted_type);
let is_generic = matches!(
result.label.as_str(),
"representation.text.word"
| "representation.text.plain_text"
| "representation.numeric.integer_number"
| "representation.numeric.decimal_number"
| "representation.numeric.increment"
| "representation.discrete.categorical"
| "datetime.component.day_of_month"
| "identity.person.username"
| "identity.person.first_name"
| "identity.person.phone_number"
| "geography.transportation.iata_code"
) || BOOLEAN_LABELS.contains(&result.label.as_str())
|| result.disambiguation_rule.as_ref()
.is_some_and(|r| r.starts_with("attractor_demotion"));
if hinted_type == "identity.person.full_name" {
const LOCATION_TYPES: &[&str] = &[
"geography.location.city",
"geography.location.country",
"geography.location.region",
"geography.location.state",
"geography.location.continent",
];
if LOCATION_TYPES.contains(&result.label.as_str()) {
result.confidence = result.confidence.max(0.5);
result.disambiguation_applied = true;
result.disambiguation_rule = Some(format!(
"header_hint_location_keep:{}",
header.to_lowercase()
));
return Ok(result);
}
if is_generic {
let top_location = result
.vote_distribution
.iter()
.filter(|(label, _)| LOCATION_TYPES.contains(&label.as_str()))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
if let Some((loc_label, loc_frac)) = top_location {
if *loc_frac >= 0.10 {
result.label = loc_label.clone();
result.confidence = loc_frac.max(0.5);
result.disambiguation_applied = true;
result.disambiguation_rule =
Some(format!("header_hint_location:{}", header.to_lowercase()));
return Ok(result);
}
}
}
}
if (result.confidence < 0.5 || is_generic) && hint_in_votes {
let hint_fraction = result
.vote_distribution
.iter()
.find(|(label, _)| label == hinted_type)
.map(|(_, frac)| *frac)
.unwrap_or(0.0);
result.label = hinted_type.to_string();
result.confidence = hint_fraction.max(0.6);
result.disambiguation_applied = true;
result.disambiguation_rule = Some(format!("header_hint:{}", header.to_lowercase()));
} else if is_generic && !hint_in_votes {
result.label = hinted_type.to_string();
result.confidence = 0.5;
result.disambiguation_applied = true;
result.disambiguation_rule =
Some(format!("header_hint_generic:{}", header.to_lowercase()));
} else if result.confidence < 0.3 && !hint_in_votes {
result.label = hinted_type.to_string();
result.confidence = 0.4;
result.disambiguation_applied = true;
result.disambiguation_rule =
Some(format!("header_hint_fallback:{}", header.to_lowercase()));
}
}
Ok(result)
}
pub fn config(&self) -> &ColumnConfig {
&self.config
}
}
const DATE_SLASH_PAIR: (&str, &str) = ("datetime.date.us_slash", "datetime.date.eu_slash");
const SHORT_DATE_PAIR: (&str, &str) = ("datetime.date.short_mdy", "datetime.date.short_dmy");
const COORDINATE_PAIR: (&str, &str) = (
"geography.coordinate.latitude",
"geography.coordinate.longitude",
);
const NUMERIC_ATTRACTORS: &[&str] = &[
"geography.address.postal_code",
"geography.address.street_number",
"identity.payment.cvv",
];
const TEXT_ATTRACTORS: &[&str] = &[
"identity.person.first_name",
"identity.person.phone_number",
"identity.person.username",
"geography.address.street_name",
];
const CODE_ATTRACTORS: &[&str] = &[
"geography.transportation.icao_code",
"identity.medical.ndc",
"identity.payment.cusip",
];
fn disambiguate(
values: &[String],
results: &[ClassificationResult],
votes: &[(String, usize)],
n_samples: usize,
taxonomy: Option<&Taxonomy>,
) -> Option<(String, String)> {
let top_labels: Vec<&str> = votes.iter().take(3).map(|(l, _)| l.as_str()).collect();
if contains_pair(&top_labels, DATE_SLASH_PAIR.0, DATE_SLASH_PAIR.1) {
if let Some(label) = disambiguate_slash_dates(values) {
return Some((label, "date_slash_disambiguation".to_string()));
}
}
if contains_pair(&top_labels, SHORT_DATE_PAIR.0, SHORT_DATE_PAIR.1) {
if let Some(label) = disambiguate_short_dates(values) {
return Some((label, "short_date_disambiguation".to_string()));
}
}
if contains_pair(&top_labels, COORDINATE_PAIR.0, COORDINATE_PAIR.1) {
if let Some(label) = disambiguate_coordinates(values) {
return Some((label, "coordinate_disambiguation".to_string()));
}
}
if let Some(label) = disambiguate_ipv4(values) {
return Some((label, "ipv4_detection".to_string()));
}
if let Some(label) = disambiguate_day_of_week(values) {
return Some((label, "day_of_week_name_detection".to_string()));
}
if let Some(label) = disambiguate_month_name(values) {
return Some((label, "month_name_detection".to_string()));
}
if let Some((label, rule)) = disambiguate_boolean_subtype(values, &top_labels) {
return Some((label, rule));
}
if let Some(label) = disambiguate_gender(values) {
return Some((label, "gender_detection".to_string()));
}
if let Some((label, rule)) = disambiguate_boolean_override(values, &top_labels) {
return Some((label, rule));
}
if let Some((label, rule)) = disambiguate_small_integer_ordinal(values, &top_labels) {
return Some((label, rule));
}
if let Some((label, rule)) = disambiguate_categorical(values, &top_labels) {
return Some((label, rule));
}
if let Some((label, rule)) = disambiguate_numeric(values, results, &top_labels) {
return Some((label, rule));
}
if top_labels
.first()
.is_some_and(|l| *l == "representation.numeric.si_number")
{
if let Some((label, rule)) = disambiguate_si_number(values) {
return Some((label, rule));
}
}
if let Some((label, rule)) = disambiguate_attractor_demotion(values, votes, n_samples, taxonomy)
{
return Some((label, rule));
}
None
}
fn contains_pair(labels: &[&str], a: &str, b: &str) -> bool {
labels.contains(&a) && labels.contains(&b)
}
fn disambiguate_day_of_week(values: &[String]) -> Option<String> {
const DAY_NAMES: &[&str] = &[
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun",
"mo",
"tu",
"we",
"th",
"fr",
"sa",
"su",
];
let non_empty: Vec<String> = values
.iter()
.map(|v| v.trim().to_lowercase())
.filter(|v| !v.is_empty())
.collect();
if non_empty.len() < 3 {
return None;
}
let matching = non_empty
.iter()
.filter(|v| DAY_NAMES.contains(&v.as_str()))
.count();
let fraction = matching as f64 / non_empty.len() as f64;
if fraction >= 0.8 {
Some("datetime.component.day_of_week".to_string())
} else {
None
}
}
fn disambiguate_month_name(values: &[String]) -> Option<String> {
const MONTH_NAMES: &[&str] = &[
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
"jan",
"feb",
"mar",
"apr",
"jun",
"jul",
"aug",
"sep",
"oct",
"nov",
"dec",
];
let non_empty: Vec<String> = values
.iter()
.map(|v| v.trim().to_lowercase())
.filter(|v| !v.is_empty())
.collect();
if non_empty.len() < 3 {
return None;
}
let matching = non_empty
.iter()
.filter(|v| MONTH_NAMES.contains(&v.as_str()))
.count();
let fraction = matching as f64 / non_empty.len() as f64;
if fraction >= 0.8 {
Some("datetime.component.month_name".to_string())
} else {
None
}
}
fn disambiguate_boolean_subtype(
values: &[String],
top_labels: &[&str],
) -> Option<(String, String)> {
let non_empty: Vec<&str> = values
.iter()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.collect();
if non_empty.len() < 3 {
return None;
}
let binary_values: &[&str] = &["0", "1"];
let terms_values: &[&str] = &[
"true", "false", "True", "False", "TRUE", "FALSE", "yes", "no", "Yes", "No", "YES", "NO",
"on", "off", "On", "Off", "ON", "OFF",
];
let initials_values: &[&str] = &["T", "F", "t", "f", "Y", "N", "y", "n"];
let binary_count = non_empty
.iter()
.filter(|v| binary_values.contains(v))
.count();
let terms_count = non_empty
.iter()
.filter(|v| terms_values.contains(v))
.count();
let initials_count = non_empty
.iter()
.filter(|v| initials_values.contains(v))
.count();
let n = non_empty.len() as f64;
let binary_frac = binary_count as f64 / n;
let terms_frac = terms_count as f64 / n;
let initials_frac = initials_count as f64 / n;
let has_boolean_vote = top_labels.iter().any(|l| BOOLEAN_LABELS.contains(l));
let max_frac = binary_frac.max(terms_frac).max(initials_frac);
if !has_boolean_vote && max_frac < 0.8 {
return None;
}
if terms_frac >= 0.8 {
Some((
"representation.boolean.terms".to_string(),
"boolean_subtype_terms".to_string(),
))
} else if initials_frac >= 0.8 {
Some((
"representation.boolean.initials".to_string(),
"boolean_subtype_initials".to_string(),
))
} else if binary_frac >= 0.8 {
let unique_values: std::collections::HashSet<&str> = non_empty.iter().copied().collect();
if unique_values.len() <= 2 {
Some((
"representation.boolean.binary".to_string(),
"boolean_subtype_binary".to_string(),
))
} else {
None
}
} else {
None
}
}
fn disambiguate_gender(values: &[String]) -> Option<String> {
const GENDER_VALUES: &[&str] = &[
"male",
"female",
"m",
"f",
"Male",
"Female",
"M",
"F",
"MALE",
"FEMALE",
"man",
"woman",
"Man",
"Woman",
"MAN",
"WOMAN",
"boy",
"girl",
"Boy",
"Girl",
"non-binary",
"Non-binary",
"Non-Binary",
"NON-BINARY",
"nonbinary",
"Nonbinary",
"other",
"Other",
"OTHER",
"prefer not to say",
"Prefer not to say",
"unknown",
"Unknown",
"UNKNOWN",
"x",
"X",
"genderqueer",
"Genderqueer",
"agender",
"Agender",
"transgender",
"Transgender",
];
let non_empty: Vec<&str> = values
.iter()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.collect();
if non_empty.len() < 3 {
return None;
}
let all_gender = non_empty.iter().all(|v| GENDER_VALUES.contains(v));
if all_gender {
Some("identity.person.gender".to_string())
} else {
None
}
}
fn disambiguate_boolean_override(
values: &[String],
top_labels: &[&str],
) -> Option<(String, String)> {
let has_boolean = top_labels.iter().any(|l| BOOLEAN_LABELS.contains(l));
if !has_boolean {
return None;
}
let non_empty: Vec<&str> = values
.iter()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.collect();
if non_empty.len() < 3 {
return None;
}
let all_single_char = non_empty.iter().all(|v| v.chars().count() == 1);
let all_digits = non_empty
.iter()
.all(|v| v.chars().all(|c| c.is_ascii_digit()));
if all_single_char && !all_digits {
let mut unique_chars: Vec<&str> = non_empty.clone();
unique_chars.sort();
unique_chars.dedup();
if unique_chars.len() >= 2 {
let is_boolean_set = unique_chars.len() == 2 && {
let set: std::collections::HashSet<&str> = unique_chars.iter().copied().collect();
set.contains("0") && set.contains("1")
|| set.contains("T") && set.contains("F")
|| set.contains("t") && set.contains("f")
|| set.contains("Y") && set.contains("N")
|| set.contains("y") && set.contains("n")
};
if !is_boolean_set {
return Some((
"representation.discrete.categorical".to_string(),
"boolean_override_single_char_categorical".to_string(),
));
}
}
}
let parsed: Vec<i64> = values
.iter()
.filter_map(|v| v.trim().parse::<i64>().ok())
.collect();
if parsed.len() >= 3 {
let mut unique: Vec<i64> = parsed.clone();
unique.sort();
unique.dedup();
let n_unique = unique.len();
let min = *unique.first().unwrap();
let max = *unique.last().unwrap();
if n_unique > 2 && (max - min) > 1 {
return Some((
"representation.numeric.integer_number".to_string(),
"boolean_override_integer_spread".to_string(),
));
}
}
None
}
fn disambiguate_small_integer_ordinal(
values: &[String],
top_labels: &[&str],
) -> Option<(String, String)> {
let misfit_types = [
"datetime.component.day_of_month",
"representation.numeric.integer_number",
"representation.numeric.increment",
];
let top_is_misfit = top_labels
.first()
.map(|l| misfit_types.contains(l))
.unwrap_or(false);
if !top_is_misfit {
return None;
}
let parsed: Vec<i64> = values
.iter()
.filter_map(|v| v.trim().parse::<i64>().ok())
.collect();
if parsed.len() < 3 {
return None;
}
let mut unique: Vec<i64> = parsed.clone();
unique.sort();
unique.dedup();
let n_unique = unique.len();
let min = *unique.first().unwrap();
let max = *unique.last().unwrap();
if (2..=10).contains(&n_unique) && min >= 0 && max <= 20 {
if n_unique == 2 && min == 0 && max == 1 {
return None;
}
if n_unique < parsed.len() {
return Some((
"representation.discrete.ordinal".to_string(),
"small_integer_ordinal".to_string(),
));
}
}
None
}
fn disambiguate_categorical(values: &[String], top_labels: &[&str]) -> Option<(String, String)> {
let non_empty: Vec<&str> = values
.iter()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.collect();
if non_empty.len() < 3 {
return None;
}
let mut unique_values: Vec<&str> = non_empty.clone();
unique_values.sort();
unique_values.dedup();
let n_unique = unique_values.len();
if non_empty.iter().all(|v| v.chars().count() == 1) && n_unique > 2 {
let all_digits = non_empty
.iter()
.all(|v| v.chars().all(|c| c.is_ascii_digit()));
if !all_digits {
return Some((
"representation.discrete.categorical".to_string(),
"categorical_single_char".to_string(),
));
}
}
let mut generic_types = vec![
"representation.text.word",
"representation.text.plain_text",
"representation.text.abbreviation",
"representation.numeric.integer_number",
"datetime.component.day_of_month",
];
generic_types.extend_from_slice(BOOLEAN_LABELS);
let top_is_generic = top_labels
.first()
.map(|l| generic_types.contains(l))
.unwrap_or(false);
if (3..=20).contains(&n_unique) && top_is_generic {
let all_short = non_empty.iter().all(|v| v.len() <= 50);
let all_numeric = non_empty.iter().all(|v| v.parse::<f64>().is_ok());
if all_short && !all_numeric {
return Some((
"representation.discrete.categorical".to_string(),
"categorical_low_cardinality".to_string(),
));
}
}
let _ = top_labels; None
}
fn header_hint(header: &str) -> Option<&'static str> {
let h = header.to_lowercase();
let h = h.replace(['_', '-'], " ");
let h = h.trim();
match h {
"email" | "e mail" | "email address" | "emailaddress" => {
return Some("identity.person.email");
}
"url" | "uri" | "link" | "href" | "website" | "homepage" => {
return Some("technology.internet.url");
}
"ip" | "ip address" | "ipaddress" | "ip addr" | "source ip" | "destination ip"
| "src ip" | "dst ip" | "server ip" | "client ip" | "remote ip" | "local ip" => {
return Some("technology.internet.ip_v4");
}
"uuid" | "guid" => {
return Some("technology.identifier.uuid");
}
"gender" | "sex" => {
return Some("identity.person.gender");
}
"age" => {
return Some("identity.person.age");
}
"latitude" | "lat" => {
return Some("geography.coordinate.latitude");
}
"longitude" | "lng" | "lon" | "long" => {
return Some("geography.coordinate.longitude");
}
"country" | "country name" => {
return Some("geography.location.country");
}
"country code" | "alpha 2" | "alpha 3" | "iso country" | "iso alpha 2" | "iso alpha 3"
| "country iso" => {
return Some("geography.location.country_code");
}
"city" | "city name" => {
return Some("geography.location.city");
}
"state" | "province" | "region" => {
return Some("geography.location.state");
}
"currency" | "currency code" => {
return Some("identity.financial.currency_code");
}
"port" => {
return Some("technology.internet.port");
}
"id" | "identifier" => {
return Some("representation.numeric.increment");
}
"sibsp" | "parch" | "siblings" | "parents" | "children" | "dependents" | "qty"
| "quantity" => {
return Some("representation.numeric.integer_number");
}
"class" | "pclass" | "grade" | "rank" | "level" | "tier" | "rating" | "priority"
| "score" => {
return Some("representation.discrete.ordinal");
}
"survived" | "alive" | "deceased" | "dead" | "active" | "enabled" | "disabled"
| "deleted" | "verified" | "approved" | "flagged" => {
return Some("representation.boolean.binary");
}
"utc offset" | "gmt offset" | "timezone offset" | "tz offset" | "utcoffset"
| "gmtoffset" => {
return Some("datetime.offset.utc");
}
"cvv" | "cvc" | "security code" | "card security" => {
return Some("identity.payment.cvv");
}
"swift" | "swift code" | "bic" | "bic code" | "swiftcode" | "biccode" => {
return Some("identity.payment.swift_bic");
}
"issn" => {
return Some("technology.code.issn");
}
"npi" | "npi number" => {
return Some("identity.medical.npi");
}
"ean" | "barcode" | "gtin" | "upc" => {
return Some("technology.code.ean");
}
"os" | "operating system" | "platform" => {
return Some("technology.development.os");
}
"occupation" | "job title" | "jobtitle" | "job" | "profession" | "role" | "position" => {
return Some("identity.person.occupation");
}
"subcountry" | "subregion" | "sub region" | "sub country" => {
return Some("geography.location.state");
}
"embarked" | "boarded" | "departed" | "terminal" | "gate" => {
return Some("representation.discrete.categorical");
}
"ticket" | "ticket number" | "ticketno" => {
return Some("representation.alphanumeric.alphanumeric_id");
}
"cabin" | "room" | "compartment" | "berth" | "seat" => {
return Some("representation.alphanumeric.alphanumeric_id");
}
"fare" | "fee" | "toll" | "charge" => {
return Some("representation.numeric.decimal_number");
}
_ => {}
}
if h.contains("email") || h.contains("e mail") {
return Some("identity.person.email");
}
if h.contains("phone") || h.contains("tel") || h.contains("mobile") || h.contains("fax") {
return Some("identity.person.phone_number");
}
if h.ends_with(" ip") || h.starts_with("ip ") || h.contains(" ip ") {
return Some("technology.internet.ip_v4");
}
if h.contains("zip") || h.contains("postal") || h.contains("postcode") {
return Some("geography.address.postal_code");
}
if h.contains("name") && (h.contains("first") || h.contains("given")) {
return Some("identity.person.first_name");
}
if h.contains("name") && (h.contains("last") || h.contains("family") || h.contains("sur")) {
return Some("identity.person.last_name");
}
if h.contains("name") && (h.contains("full") || h.contains("complete") || h == "name") {
return Some("identity.person.full_name");
}
if h == "name" || h.ends_with(" name") {
return Some("identity.person.full_name");
}
if h.contains("address") && !h.contains("email") && !h.contains("ip") {
return Some("geography.address.street_address");
}
if h.contains("street") {
return Some("geography.address.street_address");
}
if h.contains("born") || h.contains("birth") || h.contains("dob") {
return Some("datetime.date.iso_date");
}
if h.contains("date") || h.contains("timestamp") || h.contains("datetime") {
return Some("datetime.timestamp.iso_8601");
}
if h.contains("year") {
return Some("datetime.component.year");
}
if h.contains("weight") {
return Some("identity.person.weight");
}
if h.contains("height") {
return Some("identity.person.height");
}
if h.contains("password") || h.contains("passwd") {
return Some("identity.credential.password");
}
if h.contains("url") || h.contains("uri") || h.contains("link") || h.contains("href") {
return Some("technology.internet.url");
}
if h.contains("price") || h.contains("cost") || h.contains("amount") || h.contains("salary") {
return Some("representation.numeric.decimal_number");
}
if h.contains("count") || h.contains("quantity") || h.contains("num") {
return Some("representation.numeric.integer_number");
}
if h.contains("class") || h.contains("grade") || h.contains("rank") || h.contains("tier") {
return Some("representation.discrete.ordinal");
}
if h.contains("ticket") || h.contains("cabin") || h.contains("seat") || h.contains("room") {
return Some("representation.alphanumeric.alphanumeric_id");
}
if h.contains("fare") || h.contains("fee") || h.contains("charge") || h.contains("toll") {
return Some("representation.numeric.decimal_number");
}
None
}
fn disambiguate_slash_dates(values: &[String]) -> Option<String> {
let mut first_over_12 = false;
let mut second_over_12 = false;
for val in values {
let parts: Vec<&str> = val.split('/').collect();
if parts.len() >= 2 {
if let Ok(first) = parts[0].parse::<u32>() {
if first > 12 {
first_over_12 = true;
}
}
if let Ok(second) = parts[1].parse::<u32>() {
if second > 12 {
second_over_12 = true;
}
}
}
}
if first_over_12 && !second_over_12 {
Some("datetime.date.eu_slash".to_string())
} else if second_over_12 && !first_over_12 {
Some("datetime.date.us_slash".to_string())
} else {
None
}
}
fn disambiguate_short_dates(values: &[String]) -> Option<String> {
let mut first_over_12 = false;
let mut second_over_12 = false;
for val in values {
let parts: Vec<&str> = val.split('-').collect();
if parts.len() >= 2 {
if let Ok(first) = parts[0].parse::<u32>() {
if first > 12 {
first_over_12 = true;
}
}
if let Ok(second) = parts[1].parse::<u32>() {
if second > 12 {
second_over_12 = true;
}
}
}
}
if first_over_12 && !second_over_12 {
Some("datetime.date.short_dmy".to_string())
} else if second_over_12 && !first_over_12 {
Some("datetime.date.short_mdy".to_string())
} else {
None
}
}
fn disambiguate_coordinates(values: &[String]) -> Option<String> {
let mut any_over_90 = false;
let mut all_parseable = true;
let mut parsed_count = 0;
for val in values {
if let Ok(v) = val.trim().parse::<f64>() {
parsed_count += 1;
if v.abs() > 90.0 {
any_over_90 = true;
}
} else {
all_parseable = false;
}
}
if parsed_count < 3 {
return None;
}
if any_over_90 {
Some("geography.coordinate.longitude".to_string())
} else if all_parseable {
Some("geography.coordinate.latitude".to_string())
} else {
None
}
}
fn disambiguate_ipv4(values: &[String]) -> Option<String> {
let non_empty: Vec<&str> = values
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if non_empty.len() < 3 {
return None;
}
let mut ipv4_count = 0;
for val in &non_empty {
let parts: Vec<&str> = val.split('.').collect();
if parts.len() == 4 {
let all_valid = parts.iter().all(|p| {
p.parse::<u16>()
.map(|n| n <= 255 && !p.is_empty())
.unwrap_or(false)
});
if all_valid {
ipv4_count += 1;
}
}
}
let fraction = ipv4_count as f64 / non_empty.len() as f64;
if fraction >= 0.8 {
Some("technology.internet.ip_v4".to_string())
} else {
None
}
}
fn disambiguate_numeric(
values: &[String],
results: &[ClassificationResult],
top_labels: &[&str],
) -> Option<(String, String)> {
let numeric_types = [
"technology.internet.port",
"representation.numeric.increment",
"representation.numeric.integer_number",
"representation.numeric.decimal_number",
"geography.address.postal_code",
"geography.address.street_number",
"datetime.component.year",
];
let has_numeric_confusion = top_labels.iter().any(|l| numeric_types.contains(l));
if !has_numeric_confusion {
return None;
}
let parsed: Vec<i64> = values
.iter()
.filter_map(|v| v.trim().parse::<i64>().ok())
.collect();
if parsed.len() < 3 {
return None;
}
let min = *parsed.iter().min().unwrap();
let max = *parsed.iter().max().unwrap();
let range = max - min;
let mut sorted = parsed.clone();
sorted.sort();
sorted.dedup();
let is_sequential = if sorted.len() >= 3 {
let diffs: Vec<i64> = sorted.windows(2).map(|w| w[1] - w[0]).collect();
let avg_diff = diffs.iter().sum::<i64>() as f64 / diffs.len() as f64;
let variance = diffs
.iter()
.map(|d| (*d as f64 - avg_diff).powi(2))
.sum::<f64>()
/ diffs.len() as f64;
variance < (avg_diff * 0.5).powi(2) && avg_diff > 0.0
} else {
false
};
let all_in_port_range = min >= 0 && max <= 65535;
let common_ports = [80, 443, 8080, 3306, 5432, 22, 21, 25, 53, 3000, 8000, 8443];
let common_port_count = parsed.iter().filter(|v| common_ports.contains(v)).count();
let common_port_fraction = common_port_count as f64 / parsed.len() as f64;
let has_common_ports = common_port_fraction >= 0.3;
let all_positive = min > 0;
let typical_postal_range = all_positive && max <= 99999 && min >= 100;
let digit_lengths: Vec<usize> = values
.iter()
.filter_map(|v| {
let trimmed = v.trim();
if trimmed.chars().all(|c| c.is_ascii_digit()) {
Some(trimmed.len())
} else {
None
}
})
.collect();
let consistent_digits = if !digit_lengths.is_empty() {
let first_len = digit_lengths[0];
digit_lengths.iter().all(|&l| l == first_len)
} else {
false
};
let year_candidates: Vec<i64> = parsed
.iter()
.filter(|&&v| (1900..=2100).contains(&v))
.copied()
.collect();
let count_trimmed_4digit = values
.iter()
.filter(|v| {
let t = v.trim();
t.len() == 4 && t.chars().all(|c| c.is_ascii_digit())
})
.count();
let fraction_4digit = if values.is_empty() {
0.0
} else {
count_trimmed_4digit as f64 / values.len() as f64
};
let mostly_4digit = fraction_4digit >= 0.8;
let year_fraction = if parsed.is_empty() {
0.0
} else {
year_candidates.len() as f64 / parsed.len() as f64
};
let is_year_column = year_fraction >= 0.8 && parsed.len() >= 3 && mostly_4digit;
if is_year_column {
return Some((
"datetime.component.year".to_string(),
"numeric_year_detection".to_string(),
));
}
if is_sequential && min >= 0 && range > 0 {
return Some((
"representation.numeric.increment".to_string(),
"numeric_sequential_detection".to_string(),
));
}
if has_common_ports && all_in_port_range && !is_sequential {
return Some((
"technology.internet.port".to_string(),
"numeric_port_detection".to_string(),
));
}
if consistent_digits && typical_postal_range && !is_sequential {
if mostly_4digit && year_fraction >= 0.8 {
return Some((
"datetime.component.year".to_string(),
"numeric_year_detection".to_string(),
));
}
return Some((
"geography.address.postal_code".to_string(),
"numeric_postal_code_detection".to_string(),
));
}
let street_range = all_positive && max < 100000 && min >= 1;
let is_street_candidate = top_labels.contains(&"geography.address.street_number");
if is_street_candidate
&& street_range
&& !is_sequential
&& !has_common_ports
&& !consistent_digits
{
return Some((
"geography.address.street_number".to_string(),
"numeric_street_number_detection".to_string(),
));
}
let _ = results; None
}
fn disambiguate_si_number(values: &[String]) -> Option<(String, String)> {
const SI_SUFFIXES: &[char] = &['K', 'k', 'M', 'm', 'B', 'b', 'T', 't', 'G', 'g'];
let has_si_suffix = values.iter().any(|v| {
let trimmed = v.trim();
if trimmed.is_empty() {
return false;
}
trimmed
.chars()
.last()
.is_some_and(|c| SI_SUFFIXES.contains(&c))
});
if !has_si_suffix {
Some((
"representation.numeric.decimal_number".to_string(),
"si_number_override_no_suffix".to_string(),
))
} else {
None
}
}
fn disambiguate_attractor_demotion(
values: &[String],
votes: &[(String, usize)],
n_samples: usize,
taxonomy: Option<&Taxonomy>,
) -> Option<(String, String)> {
let (top_label, top_count) = votes.first()?;
let majority_fraction = *top_count as f32 / n_samples as f32;
let is_numeric = NUMERIC_ATTRACTORS.contains(&top_label.as_str());
let is_text = TEXT_ATTRACTORS.contains(&top_label.as_str());
let is_code = CODE_ATTRACTORS.contains(&top_label.as_str());
if !is_numeric && !is_text && !is_code {
return None;
}
let mut validation_confirmed = false;
if let Some(taxonomy) = taxonomy {
let has_pattern = taxonomy
.get(top_label)
.and_then(|d| d.validation.as_ref())
.and_then(|v| v.pattern.as_ref())
.is_some();
let non_empty: Vec<&str> = values
.iter()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.collect();
if non_empty.len() >= 3 {
let mut locale_confirmed = false;
if let Some(locale_validators) = taxonomy.get_locale_validators(top_label) {
let mut best_pass_rate: f32 = 0.0;
for compiled in locale_validators.values() {
let pass_count = non_empty.iter().filter(|v| compiled.is_valid(v)).count();
let pass_rate = pass_count as f32 / non_empty.len() as f32;
if pass_rate > best_pass_rate {
best_pass_rate = pass_rate;
}
}
if best_pass_rate > 0.5 {
locale_confirmed = true;
validation_confirmed = true;
}
}
if !locale_confirmed {
let fail_count = if let Some(compiled) = taxonomy.get_validator(top_label) {
non_empty.iter().filter(|v| !compiled.is_valid(v)).count()
} else if let Some(def) = taxonomy.get(top_label) {
if let Some(validation) = &def.validation {
non_empty
.iter()
.filter(|v| {
finetype_core::validate_value(v, validation)
.map(|r| !r.is_valid)
.unwrap_or(false)
})
.count()
} else {
0
}
} else {
0
};
let fail_rate = fail_count as f32 / non_empty.len() as f32;
if fail_rate > 0.5 {
let fallback = select_fallback(votes, is_numeric, is_text, is_code, values);
return Some((
fallback,
format!("attractor_demotion_validation:{}", top_label),
));
}
if has_pattern && fail_rate <= 0.3 {
validation_confirmed = true;
}
}
}
}
if !validation_confirmed && majority_fraction < 0.85 {
let fallback = select_fallback(votes, is_numeric, is_text, is_code, values);
return Some((
fallback,
format!("attractor_demotion_confidence:{}", top_label),
));
}
if is_text {
let non_empty: Vec<&str> = values
.iter()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.collect();
let mut unique: Vec<&str> = non_empty.clone();
unique.sort();
unique.dedup();
if (1..=20).contains(&unique.len()) {
return Some((
"representation.discrete.categorical".to_string(),
format!("attractor_demotion_cardinality:{}", top_label),
));
}
}
None
}
fn select_fallback(
votes: &[(String, usize)],
is_numeric: bool,
is_text: bool,
is_code: bool,
values: &[String],
) -> String {
for (label, _) in votes.iter().skip(1) {
if label.starts_with("representation.") {
return label.clone();
}
}
if is_numeric {
let has_decimal = values.iter().any(|v| v.contains('.'));
if has_decimal {
"representation.numeric.decimal_number".to_string()
} else {
"representation.numeric.integer_number".to_string()
}
} else if is_text {
"representation.discrete.categorical".to_string()
} else if is_code {
"representation.alphanumeric.alphanumeric_id".to_string()
} else {
"representation.text.word".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slash_date_eu_detected() {
let values: Vec<String> = vec![
"15/01/2024",
"28/06/2023",
"03/11/2022",
"31/12/2019",
"12/05/2020",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_slash_dates(&values);
assert_eq!(result, Some("datetime.date.eu_slash".to_string()));
}
#[test]
fn test_slash_date_us_detected() {
let values: Vec<String> = vec![
"01/15/2024",
"06/28/2023",
"11/03/2022",
"12/31/2019",
"05/12/2020",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_slash_dates(&values);
assert_eq!(result, Some("datetime.date.us_slash".to_string()));
}
#[test]
fn test_slash_date_ambiguous() {
let values: Vec<String> = vec![
"01/02/2024",
"03/04/2023",
"05/06/2022",
"07/08/2021",
"09/10/2020",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_slash_dates(&values);
assert_eq!(result, None);
}
#[test]
fn test_short_date_dmy_detected() {
let values: Vec<String> = vec!["15-01-24", "28-06-23", "31-12-19"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_short_dates(&values);
assert_eq!(result, Some("datetime.date.short_dmy".to_string()));
}
#[test]
fn test_short_date_mdy_detected() {
let values: Vec<String> = vec!["01-15-24", "06-28-23", "12-31-19"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_short_dates(&values);
assert_eq!(result, Some("datetime.date.short_mdy".to_string()));
}
#[test]
fn test_coordinates_longitude_detected() {
let values: Vec<String> = vec!["-74.0060", "151.2093", "-0.1278", "139.6917", "2.3522"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_coordinates(&values);
assert_eq!(result, Some("geography.coordinate.longitude".to_string()));
}
#[test]
fn test_coordinates_latitude_detected() {
let values: Vec<String> = vec!["40.7128", "-33.8688", "51.5074", "35.6762", "-22.9068"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_coordinates(&values);
assert_eq!(result, Some("geography.coordinate.latitude".to_string()));
}
#[test]
fn test_numeric_sequential_detection() {
let values: Vec<String> = vec!["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "representation.numeric.increment".to_string(),
confidence: 0.8,
all_scores: vec![],
})
.collect();
let votes = vec![
("representation.numeric.increment".to_string(), 8),
("representation.numeric.integer_number".to_string(), 2),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
assert!(result.is_some());
let (label, _rule) = result.unwrap();
assert_eq!(label, "representation.numeric.increment");
}
#[test]
fn test_numeric_port_detection() {
let values: Vec<String> = vec![
"80", "443", "8080", "3306", "22", "5432", "3000", "8443", "25", "53",
]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "technology.internet.port".to_string(),
confidence: 0.7,
all_scores: vec![],
})
.collect();
let votes = vec![
("technology.internet.port".to_string(), 7),
("representation.numeric.integer_number".to_string(), 3),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
assert!(result.is_some());
let (label, _rule) = result.unwrap();
assert_eq!(label, "technology.internet.port");
}
#[test]
fn test_numeric_postal_code_detection() {
let values: Vec<String> = vec![
"10001", "90210", "30301", "60601", "02101", "75001", "33101", "94102", "20001",
"98101",
]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "geography.address.postal_code".to_string(),
confidence: 0.6,
all_scores: vec![],
})
.collect();
let votes = vec![
("geography.address.postal_code".to_string(), 6),
("representation.numeric.integer_number".to_string(), 4),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
assert!(result.is_some());
let (label, _rule) = result.unwrap();
assert_eq!(label, "geography.address.postal_code");
}
#[test]
fn test_year_detection() {
let values: Vec<String> = vec![
"2020", "2019", "2021", "2018", "2023", "2015", "2022", "2017", "2024", "2016",
]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "representation.numeric.integer_number".to_string(),
confidence: 0.6,
all_scores: vec![],
})
.collect();
let votes = vec![
("representation.numeric.integer_number".to_string(), 5),
("geography.address.street_number".to_string(), 3),
("datetime.component.year".to_string(), 2),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
assert!(result.is_some());
let (label, rule) = result.unwrap();
assert_eq!(label, "datetime.component.year");
assert_eq!(rule, "numeric_year_detection");
}
#[test]
fn test_year_detection_historical() {
let values: Vec<String> = vec!["1945", "1918", "1969", "1989", "2001"]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "representation.numeric.decimal_number".to_string(),
confidence: 0.5,
all_scores: vec![],
})
.collect();
let votes = vec![
("representation.numeric.decimal_number".to_string(), 3),
("representation.numeric.integer_number".to_string(), 2),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
assert!(result.is_some());
let (label, _) = result.unwrap();
assert_eq!(label, "datetime.component.year");
}
#[test]
fn test_year_not_triggered_for_5digit_postal() {
let values: Vec<String> = vec![
"10001", "90210", "30301", "60601", "02101", "75001", "33101", "94102", "20001",
"98101",
]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "geography.address.postal_code".to_string(),
confidence: 0.6,
all_scores: vec![],
})
.collect();
let votes = vec![
("geography.address.postal_code".to_string(), 6),
("representation.numeric.integer_number".to_string(), 4),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
assert!(result.is_some());
let (label, _) = result.unwrap();
assert_eq!(label, "geography.address.postal_code");
}
#[test]
fn test_sequential_years_still_detected_as_year() {
let values: Vec<String> = vec![
"2001", "2002", "2003", "2004", "2005", "2006", "2007", "2008", "2009", "2010",
]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "representation.numeric.increment".to_string(),
confidence: 0.7,
all_scores: vec![],
})
.collect();
let votes = vec![
("representation.numeric.increment".to_string(), 7),
("representation.numeric.integer_number".to_string(), 3),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
assert!(result.is_some());
let (label, _) = result.unwrap();
assert_eq!(label, "datetime.component.year");
}
#[test]
fn test_sequential_non_year_still_increment() {
let values: Vec<String> = vec!["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "representation.numeric.increment".to_string(),
confidence: 0.8,
all_scores: vec![],
})
.collect();
let votes = vec![
("representation.numeric.increment".to_string(), 8),
("representation.numeric.integer_number".to_string(), 2),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
assert!(result.is_some());
let (label, _) = result.unwrap();
assert_eq!(label, "representation.numeric.increment");
}
#[test]
fn test_year_not_triggered_for_ports() {
let values: Vec<String> = vec!["80", "443", "8080", "3306", "22"]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "technology.internet.port".to_string(),
confidence: 0.8,
all_scores: vec![],
})
.collect();
let votes = vec![
("technology.internet.port".to_string(), 4),
("representation.numeric.integer_number".to_string(), 1),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
if let Some((label, _)) = result {
assert_ne!(label, "datetime.component.year");
}
}
#[test]
fn test_year_with_outlier_not_postal_code() {
let values: Vec<String> = vec![
"2020", "2019", "2021", "2018", "2023", "2015", "2022", "2017", "2024", "1776",
]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "geography.address.postal_code".to_string(),
confidence: 0.6,
all_scores: vec![],
})
.collect();
let votes = vec![
("geography.address.postal_code".to_string(), 5),
("representation.numeric.decimal_number".to_string(), 3),
("datetime.component.year".to_string(), 2),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
assert!(result.is_some());
let (label, _rule) = result.unwrap();
assert_eq!(label, "datetime.component.year");
}
#[test]
fn test_year_with_many_outliers_not_year() {
let values: Vec<String> = vec![
"2020", "2019", "2021", "1500", "1600", "1700", "1800", "2022", "2023", "2024",
]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "representation.numeric.integer_number".to_string(),
confidence: 0.6,
all_scores: vec![],
})
.collect();
let votes = vec![
("representation.numeric.integer_number".to_string(), 5),
("geography.address.postal_code".to_string(), 3),
("datetime.component.year".to_string(), 2),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
if let Some((label, _)) = result {
assert_ne!(label, "datetime.component.year");
}
}
#[test]
fn test_year_with_non4digit_outlier() {
let values: Vec<String> = vec![
"2020", "2019", "2021", "2018", "2023", "2015", "2022", "2017", "2024", "NA",
]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "representation.numeric.decimal_number".to_string(),
confidence: 0.6,
all_scores: vec![],
})
.collect();
let votes = vec![
("representation.numeric.decimal_number".to_string(), 8),
("datetime.component.year".to_string(), 2),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
assert!(result.is_some());
let (label, rule) = result.unwrap();
assert_eq!(label, "datetime.component.year");
assert_eq!(rule, "numeric_year_detection");
}
#[test]
fn test_year_with_decimal_format() {
let values: Vec<String> = vec![
"2020", "2019", "2021.0", "2018", "2023", "2015", "2022", "2017.0", "2024", "2016",
]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "representation.numeric.decimal_number".to_string(),
confidence: 0.7,
all_scores: vec![],
})
.collect();
let votes = vec![("representation.numeric.decimal_number".to_string(), 10)];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
assert!(result.is_some());
let (label, _) = result.unwrap();
assert_eq!(label, "datetime.component.year");
}
#[test]
fn test_not_year_when_too_few_4digit() {
let values: Vec<String> = vec![
"2020", "2019", "NA", "N/A", "", "2015", "2022", "null", "2024", "missing",
]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "representation.numeric.decimal_number".to_string(),
confidence: 0.5,
all_scores: vec![],
})
.collect();
let votes = vec![("representation.numeric.decimal_number".to_string(), 10)];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
if let Some((label, _)) = result {
assert_ne!(label, "datetime.component.year");
}
}
#[test]
fn test_age_column_not_detected_as_port() {
let values: Vec<String> = vec!["22", "25", "30", "35", "40", "45", "50", "53", "60", "70"]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "technology.internet.port".to_string(),
confidence: 0.7,
all_scores: vec![],
})
.collect();
let votes = vec![
("technology.internet.port".to_string(), 6),
("representation.numeric.integer_number".to_string(), 4),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
if let Some((label, _)) = result {
assert_ne!(label, "technology.internet.port");
}
}
#[test]
fn test_age_column_with_mixed_values_not_port() {
let values: Vec<String> = vec![
"2", "5", "14", "17", "21", "22", "25", "28", "33", "38", "42", "47", "53", "61", "75",
]
.into_iter()
.map(String::from)
.collect();
let results: Vec<ClassificationResult> = values
.iter()
.map(|_| ClassificationResult {
label: "technology.internet.port".to_string(),
confidence: 0.6,
all_scores: vec![],
})
.collect();
let votes = vec![
("technology.internet.port".to_string(), 8),
("representation.numeric.integer_number".to_string(), 7),
];
let top_labels: Vec<&str> = votes.iter().map(|(l, _)| l.as_str()).collect();
let result = disambiguate_numeric(&values, &results, &top_labels);
if let Some((label, _)) = result {
assert_ne!(label, "technology.internet.port");
}
}
#[test]
fn test_empty_column() {
let result = ColumnResult {
label: "unknown".to_string(),
confidence: 0.0,
vote_distribution: vec![],
disambiguation_applied: false,
disambiguation_rule: None,
samples_used: 0,
};
assert_eq!(result.label, "unknown");
assert_eq!(result.samples_used, 0);
}
#[test]
fn test_gender_detection_mixed_case() {
let values: Vec<String> = vec![
"male", "female", "Male", "Female", "male", "female", "male", "Female", "male", "Male",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_gender(&values);
assert_eq!(result, Some("identity.person.gender".to_string()));
}
#[test]
fn test_gender_detection_single_char() {
let values: Vec<String> = vec!["M", "F", "M", "F", "M", "F", "M", "F", "M", "F"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_gender(&values);
assert_eq!(result, Some("identity.person.gender".to_string()));
}
#[test]
fn test_gender_detection_with_nonbinary() {
let values: Vec<String> = vec![
"Male",
"Female",
"Male",
"Non-binary",
"Female",
"Male",
"Female",
"Male",
"Non-binary",
"Female",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_gender(&values);
assert_eq!(
result,
Some("identity.person.gender".to_string()),
"Non-binary should be recognized as a valid gender value"
);
}
#[test]
fn test_gender_detection_with_other_inclusive() {
let values: Vec<String> = vec![
"Male",
"Female",
"Other",
"Male",
"Female",
"Prefer not to say",
"Male",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_gender(&values);
assert_eq!(result, Some("identity.person.gender".to_string()));
}
#[test]
fn test_gender_detection_fails_for_non_gender() {
let values: Vec<String> = vec!["red", "blue", "green", "red", "blue"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_gender(&values);
assert_eq!(result, None);
}
#[test]
fn test_ipv4_detection_standard_ips() {
let values: Vec<String> = vec![
"192.168.1.1",
"10.0.0.1",
"172.16.0.1",
"8.8.8.8",
"1.2.3.4",
"10.0.0.255",
"192.168.0.100",
"255.255.255.0",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_ipv4(&values);
assert_eq!(
result,
Some("technology.internet.ip_v4".to_string()),
"Standard IPv4 addresses should be detected"
);
}
#[test]
fn test_ipv4_detection_rejects_version_numbers() {
let values: Vec<String> = vec![
"1.0.0", "2.1.3", "3.14.159", "0.2.53", "6.27.84", "4.24.59", "7.23.74",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_ipv4(&values);
assert_eq!(
result, None,
"Version numbers should NOT match IPv4 pattern"
);
}
#[test]
fn test_ipv4_detection_rejects_decimals() {
let values: Vec<String> = vec!["151.3", "165.0", "161.2", "169.1", "181.7"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_ipv4(&values);
assert_eq!(
result, None,
"Decimal numbers should NOT match IPv4 pattern"
);
}
#[test]
fn test_ipv4_detection_mixed_with_some_invalid() {
let values: Vec<String> = vec![
"10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.0.4", "10.0.0.5", "10.0.0.6", "10.0.0.7",
"10.0.0.8", "N/A", "unknown",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_ipv4(&values);
assert_eq!(
result,
Some("technology.internet.ip_v4".to_string()),
"80% valid IPs should trigger detection"
);
}
#[test]
fn test_boolean_override_integer_spread() {
let values: Vec<String> = vec!["0", "1", "2", "3", "0", "1", "4", "0", "5", "8"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec![
"representation.logical.boolean",
"representation.numeric.integer_number",
];
let result = disambiguate_boolean_override(&values, &top_labels);
assert!(result.is_some());
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.numeric.integer_number");
assert_eq!(rule, "boolean_override_integer_spread");
}
#[test]
fn test_boolean_override_preserves_real_boolean() {
let values: Vec<String> = vec!["0", "1", "0", "1", "1", "0", "0", "1", "0", "1"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.logical.boolean"];
let result = disambiguate_boolean_override(&values, &top_labels);
assert!(result.is_none());
}
#[test]
fn test_boolean_override_single_char_categorical() {
let values: Vec<String> = vec!["S", "C", "Q", "S", "S", "C", "Q", "S", "S", "C"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.logical.boolean", "representation.text.word"];
let result = disambiguate_boolean_override(&values, &top_labels);
assert!(result.is_some());
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.discrete.categorical");
assert_eq!(rule, "boolean_override_single_char_categorical");
}
#[test]
fn test_boolean_override_preserves_true_false_chars() {
let values: Vec<String> = vec!["T", "F", "T", "F", "T", "F", "T", "F"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.logical.boolean"];
let result = disambiguate_boolean_override(&values, &top_labels);
assert!(result.is_none());
}
#[test]
fn test_categorical_single_char_detection() {
let values: Vec<String> = vec!["A", "B", "C", "D", "A", "B", "C", "A", "B", "D"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.text.word"];
let result = disambiguate_categorical(&values, &top_labels);
assert!(result.is_some());
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.discrete.categorical");
assert_eq!(rule, "categorical_single_char");
}
#[test]
fn test_categorical_low_cardinality() {
let values: Vec<String> = vec![
"red", "blue", "green", "red", "blue", "green", "red", "blue", "green", "red",
]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.text.word"];
let result = disambiguate_categorical(&values, &top_labels);
assert!(result.is_some());
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.discrete.categorical");
assert_eq!(rule, "categorical_low_cardinality");
}
#[test]
fn test_categorical_not_triggered_for_high_cardinality() {
let values: Vec<String> = (1..=25).map(|i| format!("value_{}", i)).collect();
let top_labels = vec!["representation.text.word"];
let result = disambiguate_categorical(&values, &top_labels);
assert!(result.is_none());
}
#[test]
fn test_categorical_not_triggered_for_numeric_values() {
let values: Vec<String> = vec!["1", "2", "3", "1", "2", "3", "1", "2", "3", "1"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.numeric.integer_number"];
let result = disambiguate_categorical(&values, &top_labels);
assert!(result.is_none());
}
#[test]
fn test_categorical_not_triggered_for_specific_types() {
let values: Vec<String> = vec!["SYD", "LAX", "JFK", "LHR", "SYD", "LAX"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["geography.transport.iata_code"];
let result = disambiguate_categorical(&values, &top_labels);
assert!(result.is_none());
}
#[test]
fn test_header_hint_email() {
assert_eq!(header_hint("Email"), Some("identity.person.email"));
assert_eq!(header_hint("email_address"), Some("identity.person.email"));
assert_eq!(header_hint("E-Mail"), Some("identity.person.email"));
assert_eq!(header_hint("user_email"), Some("identity.person.email"));
}
#[test]
fn test_header_hint_phone() {
assert_eq!(header_hint("phone"), Some("identity.person.phone_number"));
assert_eq!(
header_hint("Phone Number"),
Some("identity.person.phone_number")
);
assert_eq!(
header_hint("telephone"),
Some("identity.person.phone_number")
);
assert_eq!(header_hint("mobile"), Some("identity.person.phone_number"));
}
#[test]
fn test_header_hint_postal() {
assert_eq!(header_hint("zip"), Some("geography.address.postal_code"));
assert_eq!(
header_hint("zip_code"),
Some("geography.address.postal_code")
);
assert_eq!(
header_hint("Postal Code"),
Some("geography.address.postal_code")
);
assert_eq!(
header_hint("postcode"),
Some("geography.address.postal_code")
);
}
#[test]
fn test_header_hint_names() {
assert_eq!(header_hint("Name"), Some("identity.person.full_name"));
assert_eq!(header_hint("full_name"), Some("identity.person.full_name"));
assert_eq!(
header_hint("first_name"),
Some("identity.person.first_name")
);
assert_eq!(header_hint("last_name"), Some("identity.person.last_name"));
assert_eq!(header_hint("surname"), Some("identity.person.last_name"));
}
#[test]
fn test_header_hint_geo() {
assert_eq!(
header_hint("latitude"),
Some("geography.coordinate.latitude")
);
assert_eq!(header_hint("lat"), Some("geography.coordinate.latitude"));
assert_eq!(
header_hint("longitude"),
Some("geography.coordinate.longitude")
);
assert_eq!(header_hint("lng"), Some("geography.coordinate.longitude"));
assert_eq!(header_hint("country"), Some("geography.location.country"));
assert_eq!(header_hint("city"), Some("geography.location.city"));
}
#[test]
fn test_header_hint_identity() {
assert_eq!(header_hint("gender"), Some("identity.person.gender"));
assert_eq!(header_hint("Sex"), Some("identity.person.gender"));
assert_eq!(header_hint("age"), Some("identity.person.age"));
assert_eq!(header_hint("Age"), Some("identity.person.age"));
}
#[test]
fn test_header_hint_tech() {
assert_eq!(header_hint("url"), Some("technology.internet.url"));
assert_eq!(header_hint("URL"), Some("technology.internet.url"));
assert_eq!(header_hint("website"), Some("technology.internet.url"));
assert_eq!(header_hint("ip_address"), Some("technology.internet.ip_v4"));
assert_eq!(header_hint("uuid"), Some("technology.identifier.uuid"));
assert_eq!(header_hint("port"), Some("technology.internet.port"));
}
#[test]
fn test_header_hint_date() {
assert_eq!(header_hint("date"), Some("datetime.timestamp.iso_8601"));
assert_eq!(
header_hint("created_date"),
Some("datetime.timestamp.iso_8601")
);
assert_eq!(header_hint("year"), Some("datetime.component.year"));
assert_eq!(header_hint("birth_date"), Some("datetime.date.iso_date"));
assert_eq!(header_hint("dob"), Some("datetime.date.iso_date"));
}
#[test]
fn test_header_hint_numeric() {
assert_eq!(
header_hint("price"),
Some("representation.numeric.decimal_number")
);
assert_eq!(
header_hint("amount"),
Some("representation.numeric.decimal_number")
);
assert_eq!(
header_hint("count"),
Some("representation.numeric.integer_number")
);
assert_eq!(header_hint("id"), Some("representation.numeric.increment"));
}
#[test]
fn test_header_hint_no_match() {
assert_eq!(header_hint("foo"), None);
assert_eq!(header_hint("xyz"), None);
assert_eq!(header_hint("data"), None);
assert_eq!(header_hint("column1"), None);
}
#[test]
fn test_header_hint_coverage() {
let test_headers = vec![
"email",
"phone",
"zip",
"postal",
"name",
"full_name",
"first_name",
"last_name",
"latitude",
"longitude",
"country",
"city",
"state",
"gender",
"age",
"url",
"ip",
"uuid",
"port",
"date",
"year",
"password",
"price",
"amount",
"count",
"address",
"street",
];
let matches: Vec<&str> = test_headers
.iter()
.filter(|h| header_hint(h).is_some())
.copied()
.collect();
assert!(
matches.len() >= 20,
"Expected at least 20 matches, got {}: {:?}",
matches.len(),
matches
);
}
#[test]
fn test_boolean_override_with_current_model_label() {
let values: Vec<String> = vec!["0", "1", "2", "3", "0", "1", "4", "0", "5", "8"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec![
"technology.development.boolean",
"representation.numeric.integer_number",
];
let result = disambiguate_boolean_override(&values, &top_labels);
assert!(
result.is_some(),
"Boolean override must trigger for technology.development.boolean"
);
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.numeric.integer_number");
assert_eq!(rule, "boolean_override_integer_spread");
}
#[test]
fn test_boolean_override_preserves_real_boolean_current_label() {
let values: Vec<String> = vec!["0", "1", "0", "1", "1", "0", "0", "1", "0", "1"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["technology.development.boolean"];
let result = disambiguate_boolean_override(&values, &top_labels);
assert!(
result.is_none(),
"Real boolean {{0,1}} must not be overridden"
);
}
#[test]
fn test_small_integer_ordinal_pclass() {
let values: Vec<String> = vec!["3", "1", "3", "1", "3", "2", "3", "1", "3", "3", "1", "2"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec![
"datetime.component.day_of_month",
"representation.numeric.integer_number",
];
let result = disambiguate_small_integer_ordinal(&values, &top_labels);
assert!(
result.is_some(),
"Small integer ordinal should fire for Pclass"
);
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.discrete.ordinal");
assert_eq!(rule, "small_integer_ordinal");
}
#[test]
fn test_small_integer_ordinal_skips_boolean() {
let values: Vec<String> = vec!["0", "1", "0", "1", "1", "0", "0", "1"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["datetime.component.day_of_month"];
let result = disambiguate_small_integer_ordinal(&values, &top_labels);
assert!(result.is_none(), "Pure {{0,1}} should not be ordinal");
}
#[test]
fn test_small_integer_ordinal_skips_large_range() {
let values: Vec<String> = vec!["1", "5", "10", "25", "50", "1", "5", "25"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["datetime.component.day_of_month"];
let result = disambiguate_small_integer_ordinal(&values, &top_labels);
assert!(
result.is_none(),
"Large-range integers should not be ordinal"
);
}
#[test]
fn test_small_integer_ordinal_ratings() {
let values: Vec<String> = vec!["5", "4", "3", "5", "2", "4", "5", "1", "3", "4", "5", "5"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec![
"datetime.component.day_of_month",
"representation.numeric.integer_number",
];
let result = disambiguate_small_integer_ordinal(&values, &top_labels);
assert!(result.is_some(), "Star ratings should be ordinal");
let (label, _) = result.unwrap();
assert_eq!(label, "representation.discrete.ordinal");
}
#[test]
fn test_header_hint_class_columns() {
assert_eq!(
header_hint("Pclass"),
Some("representation.discrete.ordinal")
);
assert_eq!(
header_hint("class"),
Some("representation.discrete.ordinal")
);
assert_eq!(
header_hint("grade"),
Some("representation.discrete.ordinal")
);
assert_eq!(
header_hint("rating"),
Some("representation.discrete.ordinal")
);
}
#[test]
fn test_header_hint_count_columns() {
assert_eq!(
header_hint("SibSp"),
Some("representation.numeric.integer_number")
);
assert_eq!(
header_hint("Parch"),
Some("representation.numeric.integer_number")
);
assert_eq!(
header_hint("siblings"),
Some("representation.numeric.integer_number")
);
assert_eq!(
header_hint("children"),
Some("representation.numeric.integer_number")
);
assert_eq!(
header_hint("qty"),
Some("representation.numeric.integer_number")
);
}
#[test]
fn test_header_hint_survival_columns() {
assert_eq!(
header_hint("Survived"),
Some("representation.boolean.binary")
);
assert_eq!(header_hint("alive"), Some("representation.boolean.binary"));
assert_eq!(header_hint("active"), Some("representation.boolean.binary"));
}
#[test]
fn test_header_hint_ticket_cabin() {
assert_eq!(
header_hint("Ticket"),
Some("representation.alphanumeric.alphanumeric_id")
);
assert_eq!(
header_hint("Cabin"),
Some("representation.alphanumeric.alphanumeric_id")
);
assert_eq!(
header_hint("seat"),
Some("representation.alphanumeric.alphanumeric_id")
);
}
#[test]
fn test_header_hint_embarked() {
assert_eq!(
header_hint("Embarked"),
Some("representation.discrete.categorical")
);
assert_eq!(
header_hint("terminal"),
Some("representation.discrete.categorical")
);
}
#[test]
fn test_header_hint_fare() {
assert_eq!(
header_hint("Fare"),
Some("representation.numeric.decimal_number")
);
assert_eq!(
header_hint("fee"),
Some("representation.numeric.decimal_number")
);
}
#[test]
fn test_header_hint_class_keyword_matching() {
assert_eq!(
header_hint("passenger_class"),
Some("representation.discrete.ordinal")
);
assert_eq!(
header_hint("ticket_class"),
Some("representation.discrete.ordinal")
);
}
#[test]
fn test_si_number_override_plain_decimals() {
let values: Vec<String> = vec!["5.1", "3.5", "1.4", "7.9", "0.2", "4.6"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_si_number(&values);
assert_eq!(
result,
Some((
"representation.numeric.decimal_number".to_string(),
"si_number_override_no_suffix".to_string()
))
);
}
#[test]
fn test_si_number_override_real_si_values() {
let values: Vec<String> = vec!["5.1K", "3.5M", "1.4B"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_si_number(&values);
assert_eq!(result, None);
}
#[test]
fn test_si_number_override_mixed_values() {
let values: Vec<String> = vec!["5.1", "3.5K", "1.4"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_si_number(&values);
assert_eq!(result, None);
}
#[test]
fn test_si_number_override_negative_decimals() {
let values: Vec<String> = vec!["-450.12", "732.57", "-1.003", "98.6"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_si_number(&values);
assert_eq!(
result,
Some((
"representation.numeric.decimal_number".to_string(),
"si_number_override_no_suffix".to_string()
))
);
}
#[test]
fn test_si_number_override_empty_values() {
let values: Vec<String> = vec!["", " ", ""].into_iter().map(String::from).collect();
let result = disambiguate_si_number(&values);
assert_eq!(
result,
Some((
"representation.numeric.decimal_number".to_string(),
"si_number_override_no_suffix".to_string()
))
);
}
#[test]
fn test_day_of_week_full_names() {
let values: Vec<String> = vec![
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
"Monday",
"Friday",
"Wednesday",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_day_of_week(&values);
assert_eq!(result, Some("datetime.component.day_of_week".to_string()));
}
#[test]
fn test_day_of_week_abbreviated() {
let values: Vec<String> = vec![
"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Mon", "Fri", "Wed",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_day_of_week(&values);
assert_eq!(result, Some("datetime.component.day_of_week".to_string()));
}
#[test]
fn test_day_of_week_two_letter() {
let values: Vec<String> = vec!["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_day_of_week(&values);
assert_eq!(result, Some("datetime.component.day_of_week".to_string()));
}
#[test]
fn test_day_of_week_not_triggered_for_names() {
let values: Vec<String> = vec!["Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_day_of_week(&values);
assert_eq!(result, None);
}
#[test]
fn test_day_of_week_too_few_values() {
let values: Vec<String> = vec!["Monday", "Tuesday"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_day_of_week(&values);
assert_eq!(result, None);
}
#[test]
fn test_day_of_week_below_threshold() {
let values: Vec<String> = vec![
"Monday", "Apple", "Banana", "Cherry", "Date", "Fig", "Grape", "Tuesday", "Kiwi",
"Lemon",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_day_of_week(&values);
assert_eq!(result, None);
}
#[test]
fn test_month_name_full_names() {
let values: Vec<String> = vec![
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_month_name(&values);
assert_eq!(result, Some("datetime.component.month_name".to_string()));
}
#[test]
fn test_month_name_abbreviated() {
let values: Vec<String> = vec![
"Jan", "Feb", "Mar", "Apr", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_month_name(&values);
assert_eq!(result, Some("datetime.component.month_name".to_string()));
}
#[test]
fn test_month_name_mixed_case() {
let values: Vec<String> = vec![
"january", "FEBRUARY", "March", "april", "MAY", "June", "july", "AUGUST",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_month_name(&values);
assert_eq!(result, Some("datetime.component.month_name".to_string()));
}
#[test]
fn test_month_name_not_triggered_for_names() {
let values: Vec<String> = vec![
"Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace", "Helen", "Ivan", "Jack",
]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_month_name(&values);
assert_eq!(result, None);
}
#[test]
fn test_month_name_too_few_values() {
let values: Vec<String> = vec!["January", "February"]
.into_iter()
.map(String::from)
.collect();
let result = disambiguate_month_name(&values);
assert_eq!(result, None);
}
#[test]
fn test_boolean_subtype_terms() {
let values: Vec<String> = vec![
"True", "False", "True", "True", "False", "True", "False", "False", "True", "False",
]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.boolean.terms"];
let result = disambiguate_boolean_subtype(&values, &top_labels);
assert!(result.is_some());
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.boolean.terms");
assert_eq!(rule, "boolean_subtype_terms");
}
#[test]
fn test_boolean_subtype_binary() {
let values: Vec<String> = vec!["0", "1", "0", "1", "1", "0", "0", "1", "0", "1"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.boolean.binary"];
let result = disambiguate_boolean_subtype(&values, &top_labels);
assert!(result.is_some());
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.boolean.binary");
assert_eq!(rule, "boolean_subtype_binary");
}
#[test]
fn test_boolean_subtype_initials() {
let values: Vec<String> = vec!["T", "F", "T", "F", "T", "T", "F", "F", "T", "F"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.boolean.initials"];
let result = disambiguate_boolean_subtype(&values, &top_labels);
assert!(result.is_some());
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.boolean.initials");
assert_eq!(rule, "boolean_subtype_initials");
}
#[test]
fn test_boolean_subtype_yes_no() {
let values: Vec<String> = vec!["yes", "no", "yes", "yes", "no", "no", "yes", "no"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.boolean.terms"];
let result = disambiguate_boolean_subtype(&values, &top_labels);
assert!(result.is_some());
let (label, _) = result.unwrap();
assert_eq!(label, "representation.boolean.terms");
}
#[test]
fn test_boolean_subtype_override_categorical() {
let values: Vec<String> = vec![
"True", "False", "True", "True", "False", "True", "False", "False",
]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.discrete.categorical"];
let result = disambiguate_boolean_subtype(&values, &top_labels);
assert!(
result.is_some(),
"Should override categorical for True/False column"
);
let (label, _) = result.unwrap();
assert_eq!(label, "representation.boolean.terms");
}
#[test]
fn test_boolean_subtype_not_triggered_for_mixed() {
let values: Vec<String> = vec!["yes", "no", "maybe", "yes", "unknown", "no"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.text.word"];
let result = disambiguate_boolean_subtype(&values, &top_labels);
assert!(result.is_none());
}
#[test]
fn test_boolean_subtype_too_few_values() {
let values: Vec<String> = vec!["True", "False"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.boolean.terms"];
let result = disambiguate_boolean_subtype(&values, &top_labels);
assert!(result.is_none());
}
#[test]
fn test_boolean_subtype_skewed_integers_not_binary() {
let values: Vec<String> = vec![
"0", "1", "0", "1", "0", "0", "1", "0", "0", "1", "2", "0", "3", "0", "1", "0", "0",
"1", "4", "0",
]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec![
"representation.numeric.integer_number",
"representation.boolean.binary",
];
let result = disambiguate_boolean_subtype(&values, &top_labels);
assert!(
result.is_none(),
"Skewed integer column (SibSp-like) should not be classified as boolean"
);
}
#[test]
fn test_boolean_subtype_pure_binary_still_works() {
let values: Vec<String> = vec!["0", "1", "1", "0", "0", "1", "0", "1", "1", "0"]
.into_iter()
.map(String::from)
.collect();
let top_labels = vec!["representation.boolean.binary"];
let result = disambiguate_boolean_subtype(&values, &top_labels);
assert!(result.is_some());
let (label, _) = result.unwrap();
assert_eq!(label, "representation.boolean.binary");
}
#[test]
fn test_classify_column_with_semantic_hint() {
use crate::semantic::SemanticHintClassifier;
let model_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("models")
.join("model2vec");
if !model_dir.join("model.safetensors").exists() {
eprintln!("Skipping semantic column integration test: models/model2vec not found");
return;
}
let semantic = SemanticHintClassifier::load(&model_dir).unwrap();
let base_classifier =
crate::inference::MockClassifier::new("representation.numeric.decimal_number");
let column_classifier = ColumnClassifier::with_semantic_hint(
Box::new(base_classifier),
ColumnConfig::default(),
semantic,
);
let values: Vec<String> = vec!["72.5", "85.0", "63.2", "90.1"]
.into_iter()
.map(String::from)
.collect();
let result = column_classifier
.classify_column_with_header(&values, "weight_kg")
.unwrap();
assert_eq!(
result.label, "identity.person.weight",
"Semantic hint for 'weight_kg' should override generic decimal_number"
);
let result2 = column_classifier
.classify_column_with_header(&values, "col1")
.unwrap();
assert_eq!(
result2.label, "representation.numeric.decimal_number",
"Generic 'col1' should not trigger semantic override"
);
}
#[test]
fn test_attractor_validation_demotion() {
let values: Vec<String> = vec![
"-200", "15000", "3500", "-50", "12000", "800", "25000", "-100", "45000", "600",
]
.into_iter()
.map(String::from)
.collect();
let votes = vec![
("identity.payment.cvv".to_string(), 9),
("representation.numeric.integer_number".to_string(), 1),
];
let yaml = r#"
identity.payment.cvv:
title: "CVV"
validation:
type: string
pattern: "^[0-9]{3,4}$"
minLength: 3
maxLength: 4
tier: [VARCHAR, identity, payment]
release_priority: 5
samples: ["123"]
"#;
let taxonomy = Taxonomy::from_yaml(yaml).unwrap();
let result = disambiguate_attractor_demotion(&values, &votes, 10, Some(&taxonomy));
assert!(
result.is_some(),
"Should demote CVV when values fail validation"
);
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.numeric.integer_number");
assert!(rule.starts_with("attractor_demotion_validation:"));
}
#[test]
fn test_attractor_confidence_demotion() {
let values: Vec<String> = vec![
"1500", "2300", "45000", "800", "99", "12", "5600", "340", "78", "4100",
]
.into_iter()
.map(String::from)
.collect();
let votes = vec![
("geography.address.postal_code".to_string(), 6),
("representation.numeric.integer_number".to_string(), 4),
];
let result = disambiguate_attractor_demotion(&values, &votes, 10, None);
assert!(
result.is_some(),
"Should demote postal_code at 0.6 confidence"
);
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.numeric.integer_number");
assert!(rule.starts_with("attractor_demotion_confidence:"));
}
#[test]
fn test_attractor_cardinality_demotion() {
let values: Vec<String> = vec![
"Soccer", "Baseball", "Tennis", "Hockey", "Soccer", "Baseball", "Tennis", "Hockey",
"Soccer", "Baseball",
]
.into_iter()
.map(String::from)
.collect();
let votes = vec![
("identity.person.first_name".to_string(), 9),
("representation.text.word".to_string(), 1),
];
let result = disambiguate_attractor_demotion(&values, &votes, 10, None);
assert!(
result.is_some(),
"Should demote first_name with 4 unique values"
);
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.discrete.categorical");
assert!(rule.starts_with("attractor_demotion_cardinality:"));
}
#[test]
fn test_attractor_cardinality_single_value() {
let values: Vec<String> = vec![
"airport", "airport", "airport", "airport", "airport", "airport", "airport", "airport",
"airport", "airport",
]
.into_iter()
.map(String::from)
.collect();
let votes = vec![("identity.person.first_name".to_string(), 10)];
let result = disambiguate_attractor_demotion(&values, &votes, 10, None);
assert!(
result.is_some(),
"Should demote first_name with 1 unique value"
);
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.discrete.categorical");
assert!(rule.starts_with("attractor_demotion_cardinality:"));
}
#[test]
fn test_attractor_validation_confirmed_skips_signal2() {
let values: Vec<String> = vec!["EGLL", "KJFK", "LFPG", "EDDF", "RJTT", "VHHH"]
.into_iter()
.map(String::from)
.collect();
let votes = vec![
("geography.transportation.icao_code".to_string(), 6),
("representation.alphanumeric.alphanumeric_id".to_string(), 4),
];
let yaml = r#"
geography.transportation.icao_code:
title: "ICAO Code"
validation:
type: string
pattern: "^[A-Z]{4}$"
minLength: 4
maxLength: 4
tier: [VARCHAR, geography, transportation]
release_priority: 5
samples: ["EGLL"]
"#;
let taxonomy = Taxonomy::from_yaml(yaml).unwrap();
let result = disambiguate_attractor_demotion(&values, &votes, 10, Some(&taxonomy));
assert!(
result.is_none(),
"Should NOT demote ICAO codes when validation confirms them"
);
}
#[test]
fn test_attractor_no_demotion_true_positive() {
let values: Vec<String> = vec![
"123", "456", "789", "012", "345", "678", "901", "234", "567", "890",
]
.into_iter()
.map(String::from)
.collect();
let votes = vec![("identity.payment.cvv".to_string(), 10)];
let yaml = r#"
identity.payment.cvv:
title: "CVV"
validation:
type: string
pattern: "^[0-9]{3,4}$"
minLength: 3
maxLength: 4
tier: [VARCHAR, identity, payment]
release_priority: 5
samples: ["123"]
"#;
let taxonomy = Taxonomy::from_yaml(yaml).unwrap();
let result = disambiguate_attractor_demotion(&values, &votes, 10, Some(&taxonomy));
assert!(
result.is_none(),
"Should NOT demote actual CVVs at high confidence"
);
}
#[test]
fn test_attractor_no_demotion_high_confidence() {
let values: Vec<String> = vec![
"10001", "90210", "30301", "60601", "02101", "75001", "33101", "94102", "20001",
"98101",
]
.into_iter()
.map(String::from)
.collect();
let votes = vec![
("geography.address.postal_code".to_string(), 9),
("representation.numeric.integer_number".to_string(), 1),
];
let yaml = r#"
geography.address.postal_code:
title: "Postal Code"
validation:
type: string
pattern: "^[0-9]{3,10}$"
minLength: 3
maxLength: 10
tier: [VARCHAR, geography, address]
release_priority: 5
samples: ["10001"]
"#;
let taxonomy = Taxonomy::from_yaml(yaml).unwrap();
let result = disambiguate_attractor_demotion(&values, &votes, 10, Some(&taxonomy));
assert!(
result.is_none(),
"Should NOT demote real postal codes at 0.9 confidence"
);
}
#[test]
fn test_select_fallback_numeric() {
let values: Vec<String> = vec!["100", "200", "300"]
.into_iter()
.map(String::from)
.collect();
let votes = vec![
("geography.address.postal_code".to_string(), 8),
("geography.address.street_number".to_string(), 2),
];
let result = select_fallback(&votes, true, false, false, &values);
assert_eq!(result, "representation.numeric.integer_number");
}
#[test]
fn test_select_fallback_text() {
let values: Vec<String> = vec!["Soccer", "Tennis"]
.into_iter()
.map(String::from)
.collect();
let votes = vec![
("identity.person.first_name".to_string(), 8),
("identity.person.username".to_string(), 2),
];
let result = select_fallback(&votes, false, true, false, &values);
assert_eq!(result, "representation.discrete.categorical");
}
#[test]
fn test_select_fallback_from_votes() {
let values: Vec<String> = vec!["100", "200"].into_iter().map(String::from).collect();
let votes = vec![
("geography.address.postal_code".to_string(), 6),
("representation.numeric.decimal_number".to_string(), 3),
("geography.address.street_number".to_string(), 1),
];
let result = select_fallback(&votes, true, false, false, &values);
assert_eq!(
result, "representation.numeric.decimal_number",
"Should use representation.* type from votes when available"
);
}
#[test]
fn test_attractor_demotion_locale_validation_demotes_salary() {
let values: Vec<String> = vec![
"102000", "245000", "112000", "350000", "178000", "195000", "267000", "188000",
"103000", "272000",
]
.into_iter()
.map(String::from)
.collect();
let votes = vec![
("geography.address.postal_code".to_string(), 9),
("representation.numeric.integer_number".to_string(), 1),
];
let yaml = r#"
geography.address.postal_code:
title: "Postal Code"
validation:
type: string
minLength: 3
maxLength: 10
maximum: 99999
validation_by_locale:
EN_US:
type: string
pattern: "^(\\d{5})(?:[ \\-](\\d{4}))?$"
minLength: 5
maxLength: 10
EN_GB:
type: string
pattern: "^[A-Z]{1,2}\\d[A-Z\\d]?\\s?\\d[A-Z]{2}$"
minLength: 5
maxLength: 8
DE:
type: string
pattern: "^\\d{5}$"
minLength: 5
maxLength: 5
tier: [VARCHAR, address]
release_priority: 4
samples: ["10001"]
"#;
let mut taxonomy = Taxonomy::from_yaml(yaml).unwrap();
taxonomy.compile_validators();
taxonomy.compile_locale_validators();
let result = disambiguate_attractor_demotion(&values, &votes, 10, Some(&taxonomy));
assert!(
result.is_some(),
"Should demote salary values despite matching universal validation"
);
let (label, rule) = result.unwrap();
assert_eq!(label, "representation.numeric.integer_number");
assert!(
rule.starts_with("attractor_demotion_validation:"),
"Should demote via validation signal, got: {}",
rule
);
}
#[test]
fn test_attractor_accepts_real_us_postal_codes() {
let values: Vec<String> = vec![
"10001", "90210", "30301", "60601", "02101", "75001", "33101", "94102", "20001",
"98101",
]
.into_iter()
.map(String::from)
.collect();
let votes = vec![
("geography.address.postal_code".to_string(), 9),
("representation.numeric.integer_number".to_string(), 1),
];
let yaml = r#"
geography.address.postal_code:
title: "Postal Code"
validation:
type: string
minLength: 3
maxLength: 10
maximum: 99999
validation_by_locale:
EN_US:
type: string
pattern: "^(\\d{5})(?:[ \\-](\\d{4}))?$"
minLength: 5
maxLength: 10
EN_GB:
type: string
pattern: "^[A-Z]{1,2}\\d[A-Z\\d]?\\s?\\d[A-Z]{2}$"
minLength: 5
maxLength: 8
DE:
type: string
pattern: "^\\d{5}$"
minLength: 5
maxLength: 5
tier: [VARCHAR, address]
release_priority: 4
samples: ["10001"]
"#;
let mut taxonomy = Taxonomy::from_yaml(yaml).unwrap();
taxonomy.compile_validators();
taxonomy.compile_locale_validators();
let result = disambiguate_attractor_demotion(&values, &votes, 10, Some(&taxonomy));
assert!(
result.is_none(),
"Should NOT demote real US ZIP codes (locale-confirmed by EN_US)"
);
}
#[test]
fn test_attractor_accepts_real_uk_postcodes() {
let values: Vec<String> = vec![
"EC1A 1BB", "W1C 1AX", "M2 5BQ", "SW1A 1AA", "B1 1BB", "LS1 1BA",
]
.into_iter()
.map(String::from)
.collect();
let votes = vec![("geography.address.postal_code".to_string(), 6)];
let yaml = r#"
geography.address.postal_code:
title: "Postal Code"
validation:
type: string
minLength: 3
maxLength: 10
maximum: 99999
validation_by_locale:
EN_US:
type: string
pattern: "^(\\d{5})(?:[ \\-](\\d{4}))?$"
minLength: 5
maxLength: 10
EN_GB:
type: string
pattern: "^[A-Z]{1,2}\\d[A-Z\\d]?\\s?\\d[A-Z]{2}$"
minLength: 5
maxLength: 8
tier: [VARCHAR, address]
release_priority: 4
samples: ["10001"]
"#;
let mut taxonomy = Taxonomy::from_yaml(yaml).unwrap();
taxonomy.compile_validators();
taxonomy.compile_locale_validators();
let result = disambiguate_attractor_demotion(&values, &votes, 6, Some(&taxonomy));
assert!(
result.is_none(),
"Should NOT demote real UK postcodes (locale-confirmed by EN_GB)"
);
}
#[test]
fn test_attractor_locale_low_confidence_accepted() {
let values: Vec<String> = vec![
"10001", "90210", "30301", "60601", "02101", "75001", "33101", "94102", "20001",
"98101",
]
.into_iter()
.map(String::from)
.collect();
let votes = vec![
("geography.address.postal_code".to_string(), 6),
("representation.numeric.integer_number".to_string(), 4),
];
let yaml = r#"
geography.address.postal_code:
title: "Postal Code"
validation:
type: string
minLength: 3
maxLength: 10
maximum: 99999
validation_by_locale:
EN_US:
type: string
pattern: "^(\\d{5})(?:[ \\-](\\d{4}))?$"
minLength: 5
maxLength: 10
tier: [VARCHAR, address]
release_priority: 4
samples: ["10001"]
"#;
let mut taxonomy = Taxonomy::from_yaml(yaml).unwrap();
taxonomy.compile_validators();
taxonomy.compile_locale_validators();
let result = disambiguate_attractor_demotion(&values, &votes, 10, Some(&taxonomy));
assert!(
result.is_none(),
"Should NOT demote real US ZIPs at low confidence when locale confirms them"
);
}
}