use super::*;
mod categorical_rules;
mod datetime_rules;
mod format_rules;
mod numeric_rules;
pub(crate) use categorical_rules::*;
pub(crate) use datetime_rules::*;
pub(crate) use format_rules::*;
pub(crate) use numeric_rules::*;
pub(crate) const NUMERIC_ATTRACTORS: &[&str] = &["geography.address.postal_code"];
pub(crate) const TEXT_ATTRACTORS: &[&str] = &[
"identity.person.first_name",
"identity.person.phone_number",
"identity.person.username",
"geography.address.street_name",
];
pub(crate) const CODE_ATTRACTORS: &[&str] = &[
"geography.transportation.icao_code",
"identity.medical.ndc",
"finance.securities.cusip",
"technology.internet.top_level_domain",
];
pub(crate) fn value_sharpen(
values: &[String],
result_label: &str,
result_confidence: f32,
taxonomy: Option<&Taxonomy>,
) -> Option<(String, String)> {
for recovery in [
iso_duration_recovery,
iana_zone_recovery,
clf_timestamp_recovery,
short_date_recovery,
] {
if let Some(hit) = recovery(values, result_label) {
return Some(hit);
}
}
if result_label == DATE_SLASH_PAIR.0 || result_label == DATE_SLASH_PAIR.1 {
if let Some(label) = disambiguate_slash_dates(values) {
return Some((label, "date_slash_disambiguation".to_string()));
}
}
if result_label == SHORT_DATE_PAIR.0 || result_label == SHORT_DATE_PAIR.1 {
if let Some(label) = disambiguate_short_dates(values) {
return Some((label, "short_date_disambiguation".to_string()));
}
}
if result_label == COORDINATE_PAIR.0 || result_label == COORDINATE_PAIR.1 {
let non_empty = non_empty_trimmed(values);
if non_empty.len() >= 3 {
let mut parseable = 0usize;
let mut out_of_range = 0usize;
for v in &non_empty {
if let Ok(val) = v.parse::<f64>() {
parseable += 1;
if val.abs() > 180.0 {
out_of_range += 1;
}
}
}
if parseable >= 3 && (out_of_range as f32 / parseable as f32) > 0.1 {
return Some((
"representation.numeric.decimal_number".to_string(),
format!(
"coordinate_plausibility_gate:out_of_range={}/{}",
out_of_range, parseable
),
));
}
}
}
if result_label == COORDINATE_PAIR.0 || result_label == 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()));
}
let top_labels_single = [result_label];
if let Some((label, rule)) = disambiguate_boolean_subtype(values, &top_labels_single) {
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_single) {
return Some((label, rule));
}
let numeric_types = [
"representation.identifier.increment",
"representation.numeric.integer_number",
"representation.numeric.decimal_number",
"geography.address.postal_code",
"datetime.component.year",
"representation.identifier.numeric_code",
];
if numeric_types.contains(&result_label) {
if let Some((label, rule)) = disambiguate_numeric(values, &[], &top_labels_single) {
return Some((label, rule));
}
}
if result_label == "representation.numeric.si_number" {
if let Some((label, rule)) = disambiguate_si_number(values) {
return Some((label, rule));
}
}
if result_label == "representation.numeric.percentage" {
let has_pct_sign = values.iter().any(|v| v.contains('%'));
if !has_pct_sign {
return Some((
"representation.numeric.decimal_number".to_string(),
"percentage_no_sign".to_string(),
));
}
}
if result_label == "geography.transportation.hs_code" {
let non_empty = non_empty_trimmed(values);
if non_empty.len() >= 3 {
let match_count = non_empty.iter().filter(|v| is_hs_code_format(v)).count();
let match_rate = match_count as f32 / non_empty.len() as f32;
if match_rate < 0.5 {
return Some((
"representation.numeric.decimal_number".to_string(),
format!("hs_code_validation_gate:match_rate={:.2}", match_rate),
));
}
}
}
if result_label == "identity.commerce.upc" {
let digit_only: Vec<&str> = values
.iter()
.map(|v| v.trim())
.filter(|v| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()))
.collect();
if digit_only.len() >= 3 {
let twelve = digit_only.iter().filter(|v| v.len() == 12).count();
let thirteen = digit_only.iter().filter(|v| v.len() == 13).count();
let eight = digit_only.iter().filter(|v| v.len() == 8).count();
let total = digit_only.len();
let twelve_rate = twelve as f32 / total as f32;
if twelve_rate < 0.5 {
let ean_rate = (thirteen + eight) as f32 / total as f32;
if ean_rate > 0.5 {
return Some((
"identity.commerce.ean".to_string(),
format!("upc_digit_count_gate:ean_rate={:.2}", ean_rate),
));
}
return Some((
"representation.identifier.numeric_code".to_string(),
format!("upc_digit_count_gate:twelve_rate={:.2}", twelve_rate),
));
}
}
}
if result_label == "identity.commerce.isrc" {
let non_empty = non_empty_trimmed(values);
if non_empty.len() >= 3 {
let isin_count = non_empty.iter().filter(|v| is_isin_format(v)).count();
let isin_rate = isin_count as f32 / non_empty.len() as f32;
if isin_rate > 0.5 {
return Some((
"finance.securities.isin".to_string(),
format!("isin_format_gate:match_rate={:.2}", isin_rate),
));
}
}
}
if result_label == "identity.government.ein" {
let non_empty = non_empty_trimmed(values);
if non_empty.len() >= 3 {
let issn_count = non_empty.iter().filter(|v| is_issn_format(v)).count();
let issn_rate = issn_count as f32 / non_empty.len() as f32;
if issn_rate > 0.5 {
return Some((
"identity.commerce.issn".to_string(),
format!("issn_format_gate:match_rate={:.2}", issn_rate),
));
}
}
}
if result_label == "finance.securities.sedol" {
if let Some((label, rule)) = disambiguate_duration_override(values) {
return Some((label, rule));
}
}
if let Some((label, rule)) = disambiguate_utc_offset_override(values) {
return Some((label, rule));
}
if let Some((label, rule)) =
sharpen_attractor_demotion(values, result_label, result_confidence, taxonomy)
{
return Some((label, rule));
}
if matches!(
result_label,
"geography.address.full_address"
| "representation.text.entity_name"
| "representation.text.word"
) {
if let Some((label, rule)) = disambiguate_text_length_demotion(
values,
&[(result_label.to_string(), 1)], ) {
return Some((label, rule));
}
}
if result_label == "geography.address.full_address" {
if let Some((label, rule)) = disambiguate_full_address_whitespace_guard(values) {
return Some((label, rule));
}
}
if result_label == "datetime.date.dmy_short_dot" {
let non_empty = non_empty_trimmed(values);
if non_empty.len() >= 3 {
let invalid_date_count = non_empty
.iter()
.filter(|v| {
let parts: Vec<&str> = v.split('.').collect();
if parts.len() != 3 {
return false;
}
let day = parts[0].parse::<u32>().unwrap_or(1);
let month = parts[1].parse::<u32>().unwrap_or(1);
let year = parts[2].parse::<u32>().unwrap_or(0);
day == 0 || day > 31 || month == 0 || month > 12 || year > 99
})
.count();
let invalid_rate = invalid_date_count as f32 / non_empty.len() as f32;
if invalid_rate >= 0.30 {
return Some((
"technology.development.version".to_string(),
format!(
"version_dmy_short_dot_gate:invalid_date={:.2}",
invalid_rate
),
));
}
}
}
if result_label == "datetime.date.compact_ym" {
let non_empty = non_empty_trimmed(values);
if non_empty.len() >= 3 {
let four_digit_count = non_empty
.iter()
.filter(|v| v.len() == 4 && v.chars().all(|c| c.is_ascii_digit()))
.count();
let four_digit_rate = four_digit_count as f32 / non_empty.len() as f32;
if four_digit_rate >= 0.90 {
let year_count = non_empty
.iter()
.filter(|v| {
v.len() == 4
&& v.parse::<u16>()
.map(|n| (1900..=2100).contains(&n))
.unwrap_or(false)
})
.count();
let year_rate = year_count as f32 / non_empty.len() as f32;
if year_rate >= 0.80 {
return Some((
"datetime.component.year".to_string(),
format!(
"year_compact_ym_gate:four_digit={:.2},year_range={:.2}",
four_digit_rate, year_rate
),
));
}
}
}
}
if result_label == "representation.scientific.measurement_unit"
|| result_label == "geography.coordinate.geohash"
|| result_label == "datetime.offset.utc"
|| result_label == "technology.internet.url"
|| result_label == "geography.index.h3"
|| result_label == "technology.cloud.aws_arn"
|| result_label == "finance.crypto.ethereum_address"
|| result_label == "identity.academic.orcid"
|| result_label == "identity.medical.cpt"
|| result_label == "technology.internet.http_method"
|| result_label == "representation.boolean.terms"
|| result_label == "geography.format.wkt"
|| result_label == "technology.internet.user_agent"
|| result_label == "geography.coordinate.mgrs"
|| result_label == "geography.coordinate.plus_code"
|| result_label == "geography.coordinate.dms"
|| result_label == "geography.transportation.iso6346"
|| result_label == "representation.scientific.inchi"
|| result_label == "finance.banking.swift_bic"
|| result_label == "container.key_value.query_string"
{
if let Some(taxonomy) = taxonomy {
if let Some((label, rule)) = schema_fail_demotion(values, result_label, taxonomy) {
return Some((label, rule));
}
}
}
if result_label == "representation.file.excel_format"
&& !crate::rhh::is_disabled("excel_format_prose_demotion")
{
let non_empty = non_empty_trimmed(values);
if non_empty.len() >= 3 {
let strip_literals = |v: &str| -> String {
let mut out = String::with_capacity(v.len());
let mut in_quote = false;
let mut in_bracket = false;
for c in v.chars() {
if in_quote {
in_quote = c != '"';
} else if in_bracket {
in_bracket = c != ']';
} else {
match c {
'"' => in_quote = true,
'[' => in_bracket = true,
_ => out.push(c),
}
}
}
out
};
let is_non_format = |v: &str| {
strip_literals(v).chars().any(|c| {
c.is_alphabetic()
&& !matches!(
c.to_ascii_lowercase(),
'a' | 'd' | 'e' | 'g' | 'h' | 'm' | 'p' | 's' | 'y'
)
})
};
let mut tok_counts: Vec<usize> = non_empty
.iter()
.map(|v| v.split_whitespace().count())
.collect();
tok_counts.sort_unstable();
let median_tokens = tok_counts[tok_counts.len() / 2];
let non_format_n = non_empty.iter().filter(|v| is_non_format(v)).count();
if median_tokens >= 2 && non_format_n * 2 > non_empty.len() {
return Some((
"representation.text.entity_name".to_string(),
format!(
"excel_format_prose_demotion:non_format={}/{}",
non_format_n,
non_empty.len()
),
));
}
}
}
if result_label == "representation.text.entity_name" {
let non_empty = non_empty_trimmed(values);
if non_empty.len() >= 4 {
let is_prose = |v: &str| {
v.split_whitespace()
.filter(|w| {
w.len() >= 4 && w.chars().all(|c| c.is_alphabetic() && c.is_lowercase())
})
.count()
>= 2
};
let mut tok_counts: Vec<usize> = non_empty
.iter()
.map(|v| v.split_whitespace().count())
.collect();
tok_counts.sort_unstable();
let median_tokens = tok_counts[tok_counts.len() / 2];
let prose_n = non_empty.iter().filter(|v| is_prose(v)).count();
if median_tokens >= 3 && prose_n * 2 >= non_empty.len() {
return Some((
"representation.text.plain_text".to_string(),
format!(
"entity_prose_override:prose={}/{}",
prose_n,
non_empty.len()
),
));
}
}
}
if result_label == "representation.text.word" {
let non_empty = non_empty_trimmed(values);
let n = non_empty.len();
if n >= 4 {
let distinct: std::collections::HashSet<&str> = non_empty.iter().copied().collect();
let d = distinct.len();
if (2..=12).contains(&d) && (d as f32 / n as f32) <= 0.6 {
return Some((
"representation.text.word".to_string(),
format!("text_vocab_override:distinct={}/{}", d, n),
));
}
}
}
None
}
pub(crate) fn schema_fail_demotion(
values: &[String],
result_label: &str,
taxonomy: &Taxonomy,
) -> Option<(String, String)> {
let non_empty = non_empty_trimmed(values);
if non_empty.len() < 3 {
return None;
}
let fail_count = if let Some(compiled) = taxonomy.get_validator(result_label) {
non_empty.iter().filter(|v| !compiled.is_valid(v)).count()
} else {
let validation = taxonomy.get(result_label)?.validation.as_ref()?;
non_empty
.iter()
.filter(|v| {
finetype_core::validate_value(v, validation)
.map(|r| !r.is_valid)
.unwrap_or(false)
})
.count()
};
let fail_rate = fail_count as f32 / non_empty.len() as f32;
if fail_rate <= 0.5 {
return None;
}
let mut unique: Vec<&str> = non_empty.clone();
unique.sort_unstable();
unique.dedup();
let fallback = if (1..=20).contains(&unique.len()) {
"representation.text.word"
} else {
"representation.identifier.alphanumeric_id"
};
Some((
fallback.to_string(),
format!(
"schema_fail_demotion:{}:fail_rate={:.2}",
result_label, fail_rate
),
))
}
pub(crate) fn sharpen_attractor_demotion(
values: &[String],
result_label: &str,
result_confidence: f32,
taxonomy: Option<&Taxonomy>,
) -> Option<(String, String)> {
let is_numeric = NUMERIC_ATTRACTORS.contains(&result_label);
let is_text = TEXT_ATTRACTORS.contains(&result_label);
let is_code = CODE_ATTRACTORS.contains(&result_label);
if !is_numeric && !is_text && !is_code {
return None;
}
let mut locale_confirmed = false;
let mut validation_confirmed = false;
let mut has_locale_validators = false;
if let Some(taxonomy) = taxonomy {
let has_precise_validation = taxonomy
.get(result_label)
.and_then(|d| d.validation.as_ref())
.map(|v| v.is_precise())
.unwrap_or(false);
let non_empty = non_empty_trimmed(values);
if non_empty.len() >= 3 {
if let Some(locale_validators) = taxonomy.get_locale_validators(result_label) {
has_locale_validators = true;
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;
}
}
if !locale_confirmed {
let fail_count = if let Some(compiled) = taxonomy.get_validator(result_label) {
non_empty.iter().filter(|v| !compiled.is_valid(v)).count()
} else if let Some(def) = taxonomy.get(result_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 = sharpen_select_fallback(is_numeric, is_text, is_code, values);
return Some((
fallback,
format!("attractor_demotion_validation:{}", result_label),
));
}
if has_precise_validation && fail_rate <= 0.3 {
validation_confirmed = true;
}
}
}
}
let confirmed = locale_confirmed || (!has_locale_validators && validation_confirmed);
if !confirmed && result_confidence < 0.85 {
let fallback = sharpen_select_fallback(is_numeric, is_text, is_code, values);
return Some((
fallback,
format!("attractor_demotion_confidence:{}", result_label),
));
}
if is_text && !locale_confirmed {
let non_empty = non_empty_trimmed(values);
let mut unique: Vec<&str> = non_empty.clone();
unique.sort();
unique.dedup();
if (1..=20).contains(&unique.len()) {
return Some((
"representation.text.word".to_string(),
format!("attractor_demotion_cardinality:{}", result_label),
));
}
}
None
}
pub(crate) fn sharpen_select_fallback(
is_numeric: bool,
is_text: bool,
is_code: bool,
values: &[String],
) -> String {
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.text.word".to_string()
} else if is_code {
"representation.identifier.alphanumeric_id".to_string()
} else {
"representation.text.word".to_string()
}
}
pub(crate) 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) {
let coord_votes: usize = votes
.iter()
.filter(|(l, _)| l == COORDINATE_PAIR.0 || l == COORDINATE_PAIR.1)
.map(|(_, c)| c)
.sum();
let top_votes = votes.first().map(|(_, c)| *c).unwrap_or(0);
let top_is_coord = votes
.first()
.map(|(l, _)| l == COORDINATE_PAIR.0 || l == COORDINATE_PAIR.1)
.unwrap_or(false);
if top_is_coord || coord_votes * 3 >= top_votes {
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_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 top_labels
.first()
.is_some_and(|l| *l == "representation.numeric.percentage")
{
let has_pct_sign = values.iter().any(|v| v.contains('%'));
if !has_pct_sign {
return Some((
"representation.numeric.decimal_number".to_string(),
"percentage_no_sign".to_string(),
));
}
}
if top_labels
.first()
.is_some_and(|l| *l == "finance.securities.sedol")
{
if let Some((label, rule)) = disambiguate_duration_override(values) {
return Some((label, rule));
}
}
if let Some((label, rule)) = disambiguate_utc_offset_override(values) {
return Some((label, rule));
}
if let Some((label, rule)) = disambiguate_attractor_demotion(values, votes, n_samples, taxonomy)
{
return Some((label, rule));
}
if let Some((label, rule)) = disambiguate_text_length_demotion(values, votes) {
return Some((label, rule));
}
None
}
pub(crate) 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 locale_confirmed = false;
let mut validation_confirmed = false;
let mut has_locale_validators = false;
if let Some(taxonomy) = taxonomy {
let has_precise_validation = taxonomy
.get(top_label)
.and_then(|d| d.validation.as_ref())
.map(|v| v.is_precise())
.unwrap_or(false);
let non_empty = non_empty_trimmed(values);
if non_empty.len() >= 3 {
if let Some(locale_validators) = taxonomy.get_locale_validators(top_label) {
has_locale_validators = true;
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;
}
}
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_precise_validation && fail_rate <= 0.3 {
validation_confirmed = true;
}
}
}
}
let confirmed = locale_confirmed || (!has_locale_validators && validation_confirmed);
if !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 && !locale_confirmed {
let non_empty = non_empty_trimmed(values);
let mut unique: Vec<&str> = non_empty.clone();
unique.sort();
unique.dedup();
if (1..=20).contains(&unique.len()) {
return Some((
"representation.text.word".to_string(),
format!("attractor_demotion_cardinality:{}", top_label),
));
}
}
None
}
pub(crate) fn schema_validation_gate(
result: &mut ColumnResult,
values: &[String],
votes: &[(String, usize)],
taxonomy: &Taxonomy,
) {
if result.label.starts_with("representation.") {
return;
}
if taxonomy.get_locale_validators(&result.label).is_some() {
return;
}
let Some(validator) = taxonomy.get_validator(&result.label) else {
return;
};
let non_empty = non_empty_trimmed(values);
if non_empty.len() < 3 {
return;
}
let fail_count = non_empty.iter().filter(|v| !validator.is_valid(v)).count();
let fail_rate = fail_count as f32 / non_empty.len() as f32;
if fail_rate <= 0.5 {
return;
}
let demoted = votes
.iter()
.find(|(label, _)| {
label != &result.label
&& taxonomy
.get_validator(label)
.map(|alt| {
let pass = non_empty.iter().filter(|v| alt.is_valid(v)).count();
pass as f32 / non_empty.len() as f32 > 0.5
})
.unwrap_or(false)
})
.map(|(label, _)| label.clone())
.unwrap_or_else(|| {
let is_numeric = non_empty.iter().all(|v| v.parse::<f64>().is_ok());
select_fallback(votes, is_numeric, !is_numeric, false, values)
});
if demoted != result.label {
result.disambiguation_applied = true;
result.disambiguation_rule = Some(format!("schema_validation_gate:{}", result.label));
result.label = demoted;
result.confidence = result.confidence.min(0.6);
result.detected_locale = None;
}
}
pub(crate) 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.text.word".to_string()
} else if is_code {
"representation.identifier.alphanumeric_id".to_string()
} else {
"representation.text.word".to_string()
}
}