use std::collections::HashSet;
pub const FEATURE_DIM: usize = 37;
pub const FEATURE_NAMES: [&str; FEATURE_DIM] = [
"is_numeric", "is_integer", "is_float", "has_leading_zero", "has_at_sign", "has_protocol_prefix", "is_uuid_like", "is_hex_string", "has_iso_date_sep", "matches_phone_pattern", "length", "digit_count", "alpha_count", "uppercase_count", "lowercase_count", "space_count", "symbol_count", "digit_ratio", "alpha_ratio", "uppercase_ratio", "unique_char_ratio", "max_digit_run", "max_alpha_run", "punctuation_density", "segment_count_dot", "segment_count_dash", "segment_count_slash", "segment_count_space", "has_mixed_case", "starts_with_digit", "ends_with_digit", "length_bucket", "has_colon", "has_dash", "has_negative_prefix", "has_percent", "decimal_places", ];
pub fn extract_features(value: &str) -> [f32; FEATURE_DIM] {
let mut f = [0.0f32; FEATURE_DIM];
let len = value.len() as f32;
let chars: Vec<char> = value.chars().collect();
let char_count = chars.len();
let mut digit_count: u32 = 0;
let mut alpha_count: u32 = 0;
let mut upper_count: u32 = 0;
let mut lower_count: u32 = 0;
let mut space_count: u32 = 0;
let mut symbol_count: u32 = 0;
let mut max_digit_run: u32 = 0;
let mut cur_digit_run: u32 = 0;
let mut max_alpha_run: u32 = 0;
let mut cur_alpha_run: u32 = 0;
for &c in &chars {
if c.is_ascii_digit() {
digit_count += 1;
cur_digit_run += 1;
if cur_digit_run > max_digit_run {
max_digit_run = cur_digit_run;
}
cur_alpha_run = 0;
} else {
cur_digit_run = 0;
if c.is_alphabetic() {
alpha_count += 1;
cur_alpha_run += 1;
if cur_alpha_run > max_alpha_run {
max_alpha_run = cur_alpha_run;
}
if c.is_uppercase() {
upper_count += 1;
} else if c.is_lowercase() {
lower_count += 1;
}
} else {
cur_alpha_run = 0;
if c.is_whitespace() {
space_count += 1;
} else {
symbol_count += 1;
}
}
}
}
f[0] = if value.parse::<f64>().is_ok() {
1.0
} else {
0.0
};
f[1] = if value.parse::<i64>().is_ok() {
1.0
} else {
0.0
};
f[2] = if value.contains('.') && value.parse::<f64>().is_ok() {
1.0
} else {
0.0
};
f[3] = if chars.len() >= 2 && chars[0] == '0' && chars[1].is_ascii_digit() {
1.0
} else {
0.0
};
f[4] = if value.contains('@') { 1.0 } else { 0.0 };
f[5] = if has_protocol_prefix(value) { 1.0 } else { 0.0 };
f[6] = if is_uuid_like(value) { 1.0 } else { 0.0 };
f[7] = if !value.is_empty() && value.chars().all(|c| c.is_ascii_hexdigit()) {
1.0
} else {
0.0
};
f[8] = if has_iso_date_separator(value) {
1.0
} else {
0.0
};
f[9] = if matches_phone_pattern(value) {
1.0
} else {
0.0
};
f[10] = len;
f[11] = digit_count as f32;
f[12] = alpha_count as f32;
f[13] = upper_count as f32;
f[14] = lower_count as f32;
f[15] = space_count as f32;
f[16] = symbol_count as f32;
let char_count_f = char_count.max(1) as f32;
f[17] = digit_count as f32 / char_count_f; f[18] = alpha_count as f32 / char_count_f; f[19] = upper_count as f32 / alpha_count.max(1) as f32;
let unique_chars: HashSet<char> = chars.iter().copied().collect();
f[20] = unique_chars.len() as f32 / char_count_f;
f[21] = max_digit_run as f32;
f[22] = max_alpha_run as f32;
f[23] = symbol_count as f32 / char_count_f;
f[24] = value.split('.').count() as f32;
f[25] = value.split('-').count() as f32;
f[26] = value.split('/').count() as f32;
f[27] = value.split_whitespace().count() as f32;
f[28] = if upper_count > 0 && lower_count > 0 {
1.0
} else {
0.0
};
f[29] = if chars.first().is_some_and(|c| c.is_ascii_digit()) {
1.0
} else {
0.0
};
f[30] = if chars.last().is_some_and(|c| c.is_ascii_digit()) {
1.0
} else {
0.0
};
f[31] = (char_count as f32 + 1.0).log2();
f[32] = if value.contains(':') { 1.0 } else { 0.0 };
f[33] = if value.contains('-') { 1.0 } else { 0.0 };
f[34] = if chars.len() >= 2 && chars[0] == '-' && chars[1].is_ascii_digit() {
1.0
} else {
0.0
};
f[35] = if value.contains('%') { 1.0 } else { 0.0 };
f[36] = fractional_digit_count(value) as f32;
f
}
fn fractional_digit_count(value: &str) -> u32 {
if value.parse::<f64>().is_err() {
return 0;
}
match value.split_once('.') {
Some((_, frac)) => {
let frac = frac.split(['e', 'E']).next().unwrap_or(frac);
frac.chars().filter(|c| c.is_ascii_digit()).count() as u32
}
None => 0,
}
}
fn has_protocol_prefix(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("ftp://")
|| lower.starts_with("ftps://")
|| lower.starts_with("ssh://")
|| lower.starts_with("s3://")
}
fn is_uuid_like(value: &str) -> bool {
let parts: Vec<&str> = value.split('-').collect();
if parts.len() != 5 {
return false;
}
let expected_lens = [8, 4, 4, 4, 12];
parts
.iter()
.zip(expected_lens.iter())
.all(|(part, &expected_len)| {
part.len() == expected_len && part.chars().all(|c| c.is_ascii_hexdigit())
})
}
fn has_iso_date_separator(value: &str) -> bool {
if let Some(t_pos) = value.find('T') {
let before = value.as_bytes().get(t_pos.wrapping_sub(1));
let after = value.as_bytes().get(t_pos + 1);
matches!((before, after), (Some(b), Some(a)) if b.is_ascii_digit() && a.is_ascii_digit())
} else {
false
}
}
fn matches_phone_pattern(value: &str) -> bool {
let trimmed = value.trim();
if let Some(rest) = trimmed.strip_prefix('+') {
return rest.chars().take(3).any(|c| c.is_ascii_digit());
}
if trimmed.starts_with('(') {
if let Some(close) = trimmed.find(')') {
let inner = &trimmed[1..close];
return inner.len() >= 2 && inner.chars().all(|c| c.is_ascii_digit());
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn feat(features: &[f32; FEATURE_DIM], name: &str) -> f32 {
let idx = FEATURE_NAMES
.iter()
.position(|&n| n == name)
.unwrap_or_else(|| panic!("Unknown feature: {}", name));
features[idx]
}
#[test]
fn test_feature_dim_matches_names() {
assert_eq!(FEATURE_NAMES.len(), FEATURE_DIM);
let unique: HashSet<&str> = FEATURE_NAMES.iter().copied().collect();
assert_eq!(unique.len(), FEATURE_DIM);
}
#[test]
fn test_numeric_features() {
let f = extract_features("42");
assert_eq!(feat(&f, "is_numeric"), 1.0);
assert_eq!(feat(&f, "is_integer"), 1.0);
assert_eq!(feat(&f, "is_float"), 0.0);
assert_eq!(feat(&f, "has_leading_zero"), 0.0);
let f = extract_features("3.14");
assert_eq!(feat(&f, "is_numeric"), 1.0);
assert_eq!(feat(&f, "is_integer"), 0.0);
assert_eq!(feat(&f, "is_float"), 1.0);
let f = extract_features("hello");
assert_eq!(feat(&f, "is_numeric"), 0.0);
assert_eq!(feat(&f, "is_integer"), 0.0);
}
#[test]
fn test_leading_zero() {
assert_eq!(feat(&extract_features("007"), "has_leading_zero"), 1.0);
assert_eq!(feat(&extract_features("0123"), "has_leading_zero"), 1.0);
assert_eq!(feat(&extract_features("00501"), "has_leading_zero"), 1.0);
assert_eq!(feat(&extract_features("0"), "has_leading_zero"), 0.0);
assert_eq!(feat(&extract_features("0.5"), "has_leading_zero"), 0.0);
assert_eq!(feat(&extract_features("100"), "has_leading_zero"), 0.0);
}
#[test]
fn test_email_features() {
let f = extract_features("user@example.com");
assert_eq!(feat(&f, "has_at_sign"), 1.0);
assert_eq!(feat(&f, "has_protocol_prefix"), 0.0);
assert!(feat(&f, "segment_count_dot") >= 2.0);
}
#[test]
fn test_url_features() {
let f = extract_features("https://example.com/path");
assert_eq!(feat(&f, "has_protocol_prefix"), 1.0);
assert!(feat(&f, "segment_count_slash") >= 3.0);
}
#[test]
fn test_uuid_features() {
let f = extract_features("550e8400-e29b-41d4-a716-446655440000");
assert_eq!(feat(&f, "is_uuid_like"), 1.0);
assert!(feat(&f, "segment_count_dash") >= 5.0);
assert_eq!(feat(&extract_features("not-a-uuid"), "is_uuid_like"), 0.0);
}
#[test]
fn test_hex_string() {
assert_eq!(feat(&extract_features("deadbeef"), "is_hex_string"), 1.0);
assert_eq!(feat(&extract_features("ABCDEF01"), "is_hex_string"), 1.0);
assert_eq!(feat(&extract_features("xyz123"), "is_hex_string"), 0.0);
assert_eq!(feat(&extract_features(""), "is_hex_string"), 0.0);
}
#[test]
fn test_iso_date_separator() {
assert_eq!(
feat(&extract_features("2024-01-15T10:30:00"), "has_iso_date_sep"),
1.0
);
assert_eq!(
feat(&extract_features("2024-01-15"), "has_iso_date_sep"),
0.0
);
assert_eq!(
feat(&extract_features("The quick"), "has_iso_date_sep"),
0.0
);
}
#[test]
fn test_phone_pattern() {
assert_eq!(
feat(
&extract_features("+1-555-123-4567"),
"matches_phone_pattern"
),
1.0
);
assert_eq!(
feat(
&extract_features("+44 20 7946 0958"),
"matches_phone_pattern"
),
1.0
);
assert_eq!(
feat(&extract_features("(555) 123-4567"), "matches_phone_pattern"),
1.0
);
assert_eq!(
feat(&extract_features("12345"), "matches_phone_pattern"),
0.0
);
}
#[test]
fn test_character_counts() {
let f = extract_features("Hello World 123!");
assert_eq!(feat(&f, "alpha_count"), 10.0);
assert_eq!(feat(&f, "digit_count"), 3.0);
assert_eq!(feat(&f, "uppercase_count"), 2.0);
assert_eq!(feat(&f, "lowercase_count"), 8.0);
assert_eq!(feat(&f, "space_count"), 2.0);
assert_eq!(feat(&f, "symbol_count"), 1.0); }
#[test]
fn test_ratios() {
let f = extract_features("1234");
assert_eq!(feat(&f, "digit_ratio"), 1.0);
assert_eq!(feat(&f, "alpha_ratio"), 0.0);
let f = extract_features("abcd");
assert_eq!(feat(&f, "digit_ratio"), 0.0);
assert_eq!(feat(&f, "alpha_ratio"), 1.0);
}
#[test]
fn test_max_runs() {
let f = extract_features("abc123def45");
assert_eq!(feat(&f, "max_digit_run"), 3.0); assert_eq!(feat(&f, "max_alpha_run"), 3.0); }
#[test]
fn test_empty_string() {
let f = extract_features("");
assert_eq!(feat(&f, "length"), 0.0);
assert_eq!(feat(&f, "digit_ratio"), 0.0);
assert_eq!(feat(&f, "alpha_ratio"), 0.0);
assert_eq!(feat(&f, "is_hex_string"), 0.0);
}
#[test]
fn test_segment_counts() {
let f = extract_features("192.168.1.1");
assert_eq!(feat(&f, "segment_count_dot"), 4.0);
assert_eq!(feat(&f, "segment_count_dash"), 1.0);
let f = extract_features("2024-01-15");
assert_eq!(feat(&f, "segment_count_dash"), 3.0);
let f = extract_features("path/to/file");
assert_eq!(feat(&f, "segment_count_slash"), 3.0);
}
#[test]
fn test_mixed_case() {
assert_eq!(feat(&extract_features("Hello"), "has_mixed_case"), 1.0);
assert_eq!(feat(&extract_features("HELLO"), "has_mixed_case"), 0.0);
assert_eq!(feat(&extract_features("hello"), "has_mixed_case"), 0.0);
assert_eq!(feat(&extract_features("12345"), "has_mixed_case"), 0.0);
}
#[test]
fn test_digit_boundaries() {
let f = extract_features("2024-01-15");
assert_eq!(feat(&f, "starts_with_digit"), 1.0);
assert_eq!(feat(&f, "ends_with_digit"), 1.0);
let f = extract_features("hello");
assert_eq!(feat(&f, "starts_with_digit"), 0.0);
assert_eq!(feat(&f, "ends_with_digit"), 0.0);
}
#[test]
fn test_length_bucket() {
let f = extract_features("a"); assert!((feat(&f, "length_bucket") - 1.0).abs() < 0.01);
let f = extract_features("abcdefg"); assert!((feat(&f, "length_bucket") - 3.0).abs() < 0.01);
}
#[test]
fn test_datetime_value() {
let f = extract_features("2024-01-15T10:30:00Z");
assert_eq!(feat(&f, "has_iso_date_sep"), 1.0);
assert_eq!(feat(&f, "starts_with_digit"), 1.0);
assert!(feat(&f, "segment_count_dash") >= 3.0);
}
#[test]
fn test_identity_value() {
let f = extract_features("john.doe@example.com");
assert_eq!(feat(&f, "has_at_sign"), 1.0);
assert_eq!(feat(&f, "has_mixed_case"), 0.0);
assert!(feat(&f, "segment_count_dot") >= 3.0);
}
#[test]
fn test_geography_value() {
let f = extract_features("New York City");
assert_eq!(feat(&f, "has_mixed_case"), 1.0);
assert_eq!(feat(&f, "is_numeric"), 0.0);
assert!(feat(&f, "space_count") >= 2.0);
}
#[test]
fn test_finance_value() {
let f = extract_features("$1,234.56");
assert_eq!(feat(&f, "is_numeric"), 0.0); assert!(feat(&f, "digit_count") >= 6.0);
assert!(feat(&f, "symbol_count") >= 2.0); }
#[test]
fn test_technology_value() {
let f = extract_features("550e8400-e29b-41d4-a716-446655440000");
assert_eq!(feat(&f, "is_uuid_like"), 1.0);
assert_eq!(feat(&f, "has_mixed_case"), 0.0); }
#[test]
fn test_representation_value() {
let f = extract_features("true");
assert_eq!(feat(&f, "is_numeric"), 0.0);
assert_eq!(feat(&f, "alpha_ratio"), 1.0);
let f = extract_features("#FF5733");
assert_eq!(feat(&f, "has_mixed_case"), 0.0);
assert!(feat(&f, "symbol_count") >= 1.0);
let f = extract_features("#ff5733Ab");
assert_eq!(feat(&f, "has_mixed_case"), 1.0);
}
#[test]
fn test_has_colon() {
assert_eq!(feat(&extract_features("nginx:latest"), "has_colon"), 1.0);
assert_eq!(feat(&extract_features("10:30:00"), "has_colon"), 1.0);
assert_eq!(feat(&extract_features("host:8080"), "has_colon"), 1.0);
assert_eq!(feat(&extract_features("hello"), "has_colon"), 0.0);
assert_eq!(feat(&extract_features("192.168.1.1"), "has_colon"), 0.0);
}
#[test]
fn test_has_dash() {
assert_eq!(feat(&extract_features("2024-01-15"), "has_dash"), 1.0);
assert_eq!(
feat(
&extract_features("550e8400-e29b-41d4-a716-446655440000"),
"has_dash"
),
1.0
);
assert_eq!(feat(&extract_features("hello"), "has_dash"), 0.0);
assert_eq!(feat(&extract_features("12345"), "has_dash"), 0.0);
}
#[test]
fn test_has_negative_prefix() {
assert_eq!(feat(&extract_features("-42"), "has_negative_prefix"), 1.0);
assert_eq!(feat(&extract_features("-3.14"), "has_negative_prefix"), 1.0);
assert_eq!(feat(&extract_features("-0.5"), "has_negative_prefix"), 1.0);
assert_eq!(
feat(&extract_features("2024-01-15"), "has_negative_prefix"),
0.0
);
assert_eq!(feat(&extract_features("-abc"), "has_negative_prefix"), 0.0);
assert_eq!(
feat(&extract_features("8471.30"), "has_negative_prefix"),
0.0
);
assert_eq!(
feat(&extract_features("6204.62.40"), "has_negative_prefix"),
0.0
);
assert_eq!(feat(&extract_features("-"), "has_negative_prefix"), 0.0);
assert_eq!(feat(&extract_features(""), "has_negative_prefix"), 0.0);
}
#[test]
fn test_has_percent() {
assert_eq!(feat(&extract_features("50%"), "has_percent"), 1.0);
assert_eq!(feat(&extract_features("3.14%"), "has_percent"), 1.0);
assert_eq!(feat(&extract_features("100"), "has_percent"), 0.0);
assert_eq!(feat(&extract_features("hello"), "has_percent"), 0.0);
assert_eq!(feat(&extract_features(""), "has_percent"), 0.0);
}
#[test]
fn test_deterministic() {
let values = [
"hello@world.com",
"2024-01-15T10:30:00",
"+1-555-123-4567",
"007",
"550e8400-e29b-41d4-a716-446655440000",
"https://example.com",
"",
"42",
];
for value in &values {
let f1 = extract_features(value);
let f2 = extract_features(value);
assert_eq!(f1, f2, "Non-deterministic for: {}", value);
}
}
#[test]
fn test_performance_10k_values() {
let values: Vec<String> = (0..10_000)
.map(|i| format!("test-value-{}-example@domain.com", i))
.collect();
let start = std::time::Instant::now();
for v in &values {
let _ = extract_features(v);
}
let elapsed = start.elapsed();
assert!(
elapsed.as_secs_f64() < 2.0,
"10k extractions took {:.3}s — exceeds 2s budget",
elapsed.as_secs_f64()
);
}
}