use crate::inference::InferenceError;
use finetype_core::validator::CompiledValidator;
use finetype_core::validator_snapshot::ValidatorSnapshot;
use finetype_core::Taxonomy;
use std::collections::{BTreeMap, HashMap, HashSet};
pub struct ValidationFeatureExtractor {
type_keys: Vec<String>,
type_index: BTreeMap<String, usize>,
pinned: Option<HashMap<String, CompiledValidator>>,
}
impl ValidationFeatureExtractor {
pub fn new(taxonomy: &Taxonomy) -> Self {
let type_keys: Vec<String> = taxonomy.labels().to_vec();
let type_index: BTreeMap<String, usize> = type_keys
.iter()
.enumerate()
.map(|(i, k)| (k.clone(), i))
.collect();
Self {
type_keys,
type_index,
pinned: None,
}
}
pub fn from_type_keys(type_keys: Vec<String>) -> Self {
let type_index: BTreeMap<String, usize> = type_keys
.iter()
.enumerate()
.map(|(i, k)| (k.clone(), i))
.collect();
Self {
type_keys,
type_index,
pinned: None,
}
}
pub fn with_snapshot(
type_keys: Vec<String>,
snapshot: &ValidatorSnapshot,
) -> Result<Self, InferenceError> {
let wanted: HashSet<&String> = type_keys.iter().collect();
let listed: HashSet<&String> = snapshot.keys().collect();
if wanted != listed {
let first5 = |a: &HashSet<&String>, b: &HashSet<&String>| {
let mut v: Vec<&str> = a.difference(b).map(|k| k.as_str()).collect();
v.sort();
v.truncate(5);
v.join(", ")
};
return Err(InferenceError::InvalidPath(format!(
"validation snapshot keys differ from the model's type_index_keys; \
missing from snapshot: [{}]; missing from model: [{}]",
first5(&wanted, &listed),
first5(&listed, &wanted),
)));
}
let pinned = snapshot
.compile()
.map_err(|e| InferenceError::InvalidPath(format!("Bad validation snapshot: {e}")))?;
let mut extractor = Self::from_type_keys(type_keys);
extractor.pinned = Some(pinned);
Ok(extractor)
}
pub fn dim(&self) -> usize {
self.type_keys.len()
}
pub fn type_keys(&self) -> &[String] {
&self.type_keys
}
pub fn index_of(&self, type_key: &str) -> Option<usize> {
self.type_index.get(type_key).copied()
}
pub fn extract(&self, values: &[&str], taxonomy: &Taxonomy) -> Vec<f32> {
let n = values.len();
if n == 0 {
return vec![0.0; self.dim()];
}
let n_f32 = n as f32;
self.type_keys
.iter()
.map(|type_key| {
let validator = match &self.pinned {
Some(pinned) => pinned.get(type_key),
None => taxonomy.get_validator(type_key),
};
match validator {
Some(validator) => {
let pass_count = values
.iter()
.filter(|v| !v.trim().is_empty() && validator.is_valid(v))
.count();
pass_count as f32 / n_f32
}
None => 0.0, }
})
.collect()
}
pub fn extract_aligned(&self, values: &[&str], taxonomy: &Taxonomy) -> Vec<f32> {
self.extract(values, taxonomy)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn load_taxonomy() -> Taxonomy {
let labels_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("labels");
let mut taxonomy = Taxonomy::from_directory(&labels_dir).unwrap();
taxonomy.compile_validators();
taxonomy
}
#[test]
fn pinned_extractor_ignores_live_rule_change() {
const KEY: &str = "test.frozen.hex_code";
let rule = |class: &str| {
format!("{KEY}:\n validation:\n type: string\n pattern: \"^[{class}]{{4}}$\"\n")
};
let mut taxonomy = Taxonomy::from_yaml(&rule("0-9a-fA-F")).unwrap();
taxonomy.compile_validators();
let keys = taxonomy.labels().to_vec();
let snapshot = ValidatorSnapshot::from_taxonomy(&taxonomy, &keys);
let pinned = ValidationFeatureExtractor::with_snapshot(keys.clone(), &snapshot).unwrap();
let unpinned = ValidationFeatureExtractor::from_type_keys(keys);
let mut tightened = Taxonomy::from_yaml(&rule("0-9a-f")).unwrap();
tightened.compile_validators();
let values = ["ABCD"; 5];
let idx = pinned.index_of(KEY).unwrap();
assert_eq!(pinned.extract(&values, &tightened)[idx], 1.0);
assert_eq!(unpinned.extract(&values, &tightened)[idx], 0.0);
}
#[test]
fn pinned_extractor_never_falls_back_for_a_ruleless_key() {
let taxonomy = load_taxonomy();
let mut keys = taxonomy.labels().to_vec();
keys.push("test.frozen.no_rule".to_string());
keys.sort();
let snapshot = ValidatorSnapshot::from_taxonomy(&taxonomy, &keys);
let pinned = ValidationFeatureExtractor::with_snapshot(keys, &snapshot).unwrap();
let live = Taxonomy::from_yaml(
"test.frozen.no_rule:\n validation:\n type: string\n pattern: \".*\"\n",
);
let mut live = live.unwrap();
live.compile_validators();
assert!(live.get_validator("test.frozen.no_rule").is_some());
let idx = pinned.index_of("test.frozen.no_rule").unwrap();
assert_eq!(pinned.extract(&["abc"], &live)[idx], 0.0);
}
#[test]
fn extractor_without_snapshot_matches_today() {
let taxonomy = load_taxonomy();
let keys = taxonomy.labels().to_vec();
let from_keys = ValidationFeatureExtractor::from_type_keys(keys);
let from_tax = ValidationFeatureExtractor::new(&taxonomy);
let values = [
"US",
"40.7",
"550e8400-e29b-41d4-a716-446655440000",
"a@b.com",
];
let a = from_keys.extract(&values, &taxonomy);
let b = from_tax.extract(&values, &taxonomy);
assert_eq!(
a.iter().map(|f| f.to_bits()).collect::<Vec<_>>(),
b.iter().map(|f| f.to_bits()).collect::<Vec<_>>()
);
assert!(a.iter().any(|&f| f > 0.0));
}
#[test]
fn snapshot_key_mismatch_is_an_error() {
let taxonomy = load_taxonomy();
let keys = taxonomy.labels().to_vec();
let mut short = keys.clone();
short.pop();
let snapshot = ValidatorSnapshot::from_taxonomy(&taxonomy, &short);
assert!(ValidationFeatureExtractor::with_snapshot(keys, &snapshot).is_err());
}
#[test]
fn ac02_country_codes_discriminate_country_vs_country_code() {
let taxonomy = load_taxonomy();
let extractor = ValidationFeatureExtractor::new(&taxonomy);
let values: Vec<&str> = vec!["US", "FR", "JP"];
let features = extractor.extract(&values, &taxonomy);
assert_eq!(features.len(), extractor.dim());
assert_eq!(features.len(), taxonomy.labels().len());
let cc_idx = extractor
.index_of("geography.location.country_code")
.unwrap();
assert!(
features[cc_idx] > 0.99,
"country_code pass rate should be ~1.0, got {}",
features[cc_idx]
);
let _country_idx = extractor.index_of("geography.location.country").unwrap();
let email_idx = extractor.index_of("identity.person.email").unwrap();
assert!(
features[email_idx] < 0.01,
"email pass rate should be ~0.0, got {}",
features[email_idx]
);
let url_idx = extractor.index_of("technology.internet.url").unwrap();
assert!(
features[url_idx] < 0.01,
"url pass rate should be ~0.0, got {}",
features[url_idx]
);
}
#[test]
fn ac02_latitude_range_discriminates_from_decimal_number() {
let taxonomy = load_taxonomy();
let extractor = ValidationFeatureExtractor::new(&taxonomy);
let values: Vec<&str> = vec!["40.7", "-33.8", "91.5"];
let features = extractor.extract(&values, &taxonomy);
let lat_idx = extractor.index_of("geography.coordinate.latitude").unwrap();
let dec_idx = extractor
.index_of("representation.numeric.decimal_number")
.unwrap();
let expected_lat = 2.0 / 3.0;
assert!(
(features[lat_idx] - expected_lat).abs() < 0.01,
"latitude pass rate should be ~{:.3}, got {}",
expected_lat,
features[lat_idx]
);
assert!(
features[dec_idx] > 0.99,
"decimal_number pass rate should be ~1.0, got {}",
features[dec_idx]
);
}
#[test]
fn ac02_empty_values_returns_zeros() {
let taxonomy = load_taxonomy();
let extractor = ValidationFeatureExtractor::new(&taxonomy);
let features = extractor.extract(&[], &taxonomy);
assert_eq!(features.len(), extractor.dim());
assert!(features.iter().all(|&f| f == 0.0));
}
#[test]
fn ac02_email_display_discriminates_from_email() {
let taxonomy = load_taxonomy();
let extractor = ValidationFeatureExtractor::new(&taxonomy);
let values: Vec<&str> = vec!["John Doe <john@example.com>", "Jane Smith <jane@corp.com>"];
let features = extractor.extract(&values, &taxonomy);
let email_idx = extractor.index_of("identity.person.email").unwrap();
let display_idx = extractor.index_of("identity.person.email_display").unwrap();
assert!(
features[display_idx] > 0.5,
"email_display pass rate should be high, got {}",
features[display_idx]
);
assert!(
features[email_idx] < 0.5,
"email pass rate should be low for display-format values, got {}",
features[email_idx]
);
}
#[test]
fn ac02_type_index_mapping_consistency() {
let taxonomy = load_taxonomy();
let extractor = ValidationFeatureExtractor::new(&taxonomy);
let keys = extractor.type_keys();
for i in 1..keys.len() {
assert!(
keys[i] > keys[i - 1],
"type_keys should be sorted: {} > {} failed",
keys[i],
keys[i - 1]
);
}
let restored = ValidationFeatureExtractor::from_type_keys(keys.to_vec());
assert_eq!(restored.dim(), extractor.dim());
for key in keys {
assert_eq!(
restored.index_of(key),
extractor.index_of(key),
"index mismatch for {}",
key
);
}
}
#[test]
fn ac02_benchmark_100_values_239_types() {
let taxonomy = load_taxonomy();
let extractor = ValidationFeatureExtractor::new(&taxonomy);
let values: Vec<String> = (0..100)
.map(|i| match i % 5 {
0 => format!("192.168.1.{}", i),
1 => format!("user{}@example.com", i),
2 => format!("{}.{}", 40 + (i % 50), i * 7 % 1000),
3 => format!("2024-01-{:02}", (i % 28) + 1),
_ => format!("value_{}", i),
})
.collect();
let value_refs: Vec<&str> = values.iter().map(|s| s.as_str()).collect();
let start = std::time::Instant::now();
let features = extractor.extract(&value_refs, &taxonomy);
let elapsed = start.elapsed();
assert_eq!(features.len(), extractor.dim());
let budget_ms = if cfg!(debug_assertions) { 500 } else { 50 };
assert!(
elapsed.as_millis() < budget_ms,
"100 values x {} types took {}ms (budget: <{}ms)",
extractor.dim(),
elapsed.as_millis(),
budget_ms,
);
}
}