use crate::inference::InferenceError;
use candle_core::{DType, Device, Tensor};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct SemanticHintResult {
pub label: String,
pub similarity: f32,
}
pub struct SemanticHintClassifier {
tokenizer: tokenizers::Tokenizer,
embeddings: Tensor,
type_embeddings: Tensor,
label_index: Vec<String>,
threshold: f32,
device: Device,
}
const DEFAULT_THRESHOLD: f32 = 0.70;
impl SemanticHintClassifier {
pub fn load<P: AsRef<Path>>(model_dir: P) -> Result<Self, InferenceError> {
let dir = model_dir.as_ref();
let tokenizer_bytes = std::fs::read(dir.join("tokenizer.json"))?;
let model_bytes = std::fs::read(dir.join("model.safetensors"))?;
let type_emb_bytes = std::fs::read(dir.join("type_embeddings.safetensors"))?;
let label_bytes = std::fs::read(dir.join("label_index.json"))?;
Self::from_bytes(
&tokenizer_bytes,
&model_bytes,
&type_emb_bytes,
&label_bytes,
)
}
pub fn from_bytes(
tokenizer_bytes: &[u8],
model_bytes: &[u8],
type_emb_bytes: &[u8],
label_bytes: &[u8],
) -> Result<Self, InferenceError> {
let device = Device::Cpu;
let tokenizer = tokenizers::Tokenizer::from_bytes(tokenizer_bytes).map_err(|e| {
InferenceError::InvalidPath(format!("Failed to load Model2Vec tokenizer: {}", e))
})?;
let model_tensors = candle_core::safetensors::load_buffer(model_bytes, &device)?;
let embeddings = model_tensors
.get("embeddings")
.ok_or_else(|| {
InferenceError::InvalidPath(
"Missing 'embeddings' tensor in model.safetensors".into(),
)
})?
.to_dtype(DType::F32)?;
let type_tensors = candle_core::safetensors::load_buffer(type_emb_bytes, &device)?;
let type_embeddings = type_tensors
.get("type_embeddings")
.ok_or_else(|| {
InferenceError::InvalidPath(
"Missing 'type_embeddings' tensor in type_embeddings.safetensors".into(),
)
})?
.to_dtype(DType::F32)?;
let label_index: Vec<String> = serde_json::from_slice(label_bytes).map_err(|e| {
InferenceError::InvalidPath(format!("Failed to parse label_index.json: {}", e))
})?;
Ok(Self {
tokenizer,
embeddings,
type_embeddings,
label_index,
threshold: DEFAULT_THRESHOLD,
device,
})
}
pub fn with_threshold(mut self, threshold: f32) -> Self {
self.threshold = threshold;
self
}
pub fn threshold(&self) -> f32 {
self.threshold
}
pub fn classify_header(&self, header: &str) -> Option<SemanticHintResult> {
let normalized = header
.to_lowercase()
.replace(['_', '-', '.'], " ")
.trim()
.to_string();
if normalized.is_empty() {
return None;
}
let encoding = self.tokenizer.encode(normalized, false).ok()?;
let ids = encoding.get_ids();
if ids.is_empty() {
return None;
}
let valid_ids: Vec<u32> = ids.iter().copied().filter(|&id| id != 0).collect();
if valid_ids.is_empty() {
return None;
}
let id_tensor = Tensor::new(valid_ids.as_slice(), &self.device).ok()?;
let token_embeds = self.embeddings.index_select(&id_tensor, 0).ok()?;
let mean_embed = token_embeds.mean(0).ok()?;
let norm = mean_embed
.sqr()
.ok()?
.sum_all()
.ok()?
.sqrt()
.ok()?
.to_scalar::<f32>()
.ok()?;
if norm < 1e-8 {
return None;
}
let query = (mean_embed / norm as f64).ok()?;
let query_2d = query.unsqueeze(1).ok()?; let similarities = self.type_embeddings.matmul(&query_2d).ok()?; let similarities = similarities.squeeze(1).ok()?;
let sim_vec: Vec<f32> = similarities.to_vec1().ok()?;
let (best_idx, best_sim) = sim_vec
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))?;
if *best_sim >= self.threshold {
Some(SemanticHintResult {
label: self.label_index[best_idx].clone(),
similarity: *best_sim,
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_classifier() -> Result<SemanticHintClassifier, InferenceError> {
let device = Device::Cpu;
let tokenizer_json = r###"{
"version": "1.0",
"model": {
"type": "WordPiece",
"unk_token": "[UNK]",
"continuing_subword_prefix": "##",
"max_input_chars_per_word": 100,
"vocab": {
"[PAD]": 0,
"[UNK]": 1,
"email": 2,
"phone": 3,
"number": 4,
"data": 5
}
},
"normalizer": {
"type": "BertNormalizer",
"clean_text": true,
"handle_chinese_chars": true,
"strip_accents": null,
"lowercase": true
},
"pre_tokenizer": { "type": "BertPreTokenizer" }
}"###;
let tokenizer = tokenizers::Tokenizer::from_bytes(tokenizer_json.as_bytes())
.map_err(|e| InferenceError::InvalidPath(format!("test tokenizer: {e}")))?;
let emb_data: Vec<f32> = vec![
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.5, 0.5, 0.1, 0.1, 0.1, 0.1, ];
let embeddings = Tensor::from_vec(emb_data, (6, 4), &device)?;
let type_data: Vec<f32> = vec![
0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, ];
let type_embeddings = Tensor::from_vec(type_data, (2, 4), &device)?;
let label_index = vec![
"identity.person.email".to_string(),
"identity.person.phone_number".to_string(),
];
Ok(SemanticHintClassifier {
tokenizer,
embeddings,
type_embeddings,
label_index,
threshold: DEFAULT_THRESHOLD,
device,
})
}
#[test]
fn test_cosine_similarity_math() {
let device = Device::Cpu;
let a = Tensor::new(&[0.0f32, 1.0, 0.0, 0.0], &device).unwrap();
let b = Tensor::new(&[0.0f32, 1.0, 0.0, 0.0], &device).unwrap();
let sim = (&a * &b)
.unwrap()
.sum_all()
.unwrap()
.to_scalar::<f32>()
.unwrap();
assert!((sim - 1.0).abs() < 1e-5);
let c = Tensor::new(&[0.0f32, 0.0, 1.0, 0.0], &device).unwrap();
let sim2 = (&a * &c)
.unwrap()
.sum_all()
.unwrap()
.to_scalar::<f32>()
.unwrap();
assert!(sim2.abs() < 1e-5);
}
#[test]
fn test_classify_header_known_types() {
let classifier = make_test_classifier().unwrap();
let result = classifier.classify_header("email");
assert!(result.is_some(), "Expected match for 'email'");
let r = result.unwrap();
assert_eq!(r.label, "identity.person.email");
assert!(
r.similarity > 0.9,
"Expected high similarity, got {}",
r.similarity
);
let result = classifier.classify_header("phone");
assert!(result.is_some(), "Expected match for 'phone'");
let r = result.unwrap();
assert_eq!(r.label, "identity.person.phone_number");
assert!(
r.similarity > 0.9,
"Expected high similarity, got {}",
r.similarity
);
}
#[test]
fn test_classify_header_no_match() {
let classifier = make_test_classifier().unwrap();
let result = classifier.classify_header("data");
assert!(
result.is_none(),
"Expected no match for 'data', got {:?}",
result
);
}
#[test]
fn test_classify_header_empty() {
let classifier = make_test_classifier().unwrap();
assert!(classifier.classify_header("").is_none());
}
#[test]
fn test_classify_header_normalisation() {
let classifier = make_test_classifier().unwrap();
let result = classifier.classify_header("user_email");
assert!(result.is_some(), "Expected match for 'user_email'");
let r = result.unwrap();
assert_eq!(r.label, "identity.person.email");
}
#[test]
fn test_threshold_boundary() {
let low_threshold = SemanticHintClassifier {
threshold: 0.0,
..make_test_classifier().unwrap()
};
let result = low_threshold.classify_header("data");
assert!(result.is_some(), "With threshold=0, 'data' should match");
let high_threshold = SemanticHintClassifier {
threshold: 0.99,
..make_test_classifier().unwrap()
};
let result = high_threshold.classify_header("email");
assert!(
result.is_some(),
"With threshold=0.99, exact 'email' should still match"
);
}
#[test]
fn test_from_files_if_available() {
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 integration test: models/model2vec not found");
return;
}
let classifier = SemanticHintClassifier::load(&model_dir).unwrap();
let test_cases = vec![
("email", "identity.person.email"),
("phone_number", "identity.person.phone_number"),
("zip_code", "geography.address.postal_code"),
("latitude", "geography.coordinate.latitude"),
("longitude", "geography.coordinate.longitude"),
("first_name", "identity.person.first_name"),
("country", "geography.location.country"),
("gender", "identity.person.gender"),
("age", "identity.person.age"),
("url", "technology.internet.url"),
];
for (header, expected_label) in &test_cases {
let result = classifier.classify_header(header);
assert!(
result.is_some(),
"Expected match for '{}' -> '{}', got None",
header,
expected_label
);
let r = result.unwrap();
assert_eq!(
r.label, *expected_label,
"For '{}': expected '{}', got '{}' (sim={:.3})",
header, expected_label, r.label, r.similarity
);
}
let generic_names = vec![
"foo", "col1", "column_a", "V1", "field_3", "xyz", "data", "value", "col", "column",
"result", "output", "input", "var1",
];
for name in &generic_names {
let result = classifier.classify_header(name);
assert!(
result.is_none(),
"Expected no match for generic name '{}', got {:?}",
name,
result
);
}
}
}