use crate::inference::InferenceError;
use crate::model2vec_shared::Model2VecResources;
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>,
k: usize,
threshold: f32,
device: Device,
}
const DEFAULT_THRESHOLD: f32 = 0.65;
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 resources = Model2VecResources::from_bytes(tokenizer_bytes, model_bytes)?;
Self::from_shared(&resources, type_emb_bytes, label_bytes)
}
pub fn from_shared(
resources: &Model2VecResources,
type_emb_bytes: &[u8],
label_bytes: &[u8],
) -> Result<Self, InferenceError> {
let device = Device::Cpu;
let (type_embeddings, label_index, k) =
Self::load_type_embeddings(type_emb_bytes, label_bytes, &device)?;
Ok(Self {
tokenizer: resources.tokenizer().clone(),
embeddings: resources.embeddings().clone(),
type_embeddings,
label_index,
k,
threshold: DEFAULT_THRESHOLD,
device,
})
}
fn load_type_embeddings(
type_emb_bytes: &[u8],
label_bytes: &[u8],
device: &Device,
) -> Result<(Tensor, Vec<String>, usize), InferenceError> {
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))
})?;
let n_labels = label_index.len();
let n_rows = type_embeddings.dim(0)?;
if n_labels == 0 || n_rows % n_labels != 0 {
return Err(InferenceError::InvalidPath(format!(
"type_embeddings rows ({}) must be a multiple of label_index length ({})",
n_rows, n_labels,
)));
}
let k = n_rows / n_labels;
Ok((type_embeddings, label_index, k))
}
pub fn with_threshold(mut self, threshold: f32) -> Self {
self.threshold = threshold;
self
}
pub fn threshold(&self) -> f32 {
self.threshold
}
pub fn tokenizer(&self) -> &tokenizers::Tokenizer {
&self.tokenizer
}
pub fn embeddings(&self) -> &Tensor {
&self.embeddings
}
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 all_sims = self.type_embeddings.matmul(&query_2d).ok()?; let all_sims = all_sims.squeeze(1).ok()?;
let n_types = self.label_index.len();
let sim_vec: Vec<f32> = all_sims.to_vec1().ok()?;
let (best_idx, best_sim) = (0..n_types)
.map(|t| {
let start = t * self.k;
let end = start + self.k;
sim_vec[start..end]
.iter()
.cloned()
.fold(f32::NEG_INFINITY, f32::max)
})
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))?;
let best_sim = &best_sim;
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_tokenizer() -> Result<tokenizers::Tokenizer, InferenceError> {
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" }
}"###;
tokenizers::Tokenizer::from_bytes(tokenizer_json.as_bytes())
.map_err(|e| InferenceError::InvalidPath(format!("test tokenizer: {e}")))
}
fn make_test_token_embeddings(device: &Device) -> Result<Tensor, InferenceError> {
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, ];
Ok(Tensor::from_vec(emb_data, (6, 4), device)?)
}
fn make_test_classifier() -> Result<SemanticHintClassifier, InferenceError> {
let device = Device::Cpu;
let tokenizer = make_test_tokenizer()?;
let embeddings = make_test_token_embeddings(&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,
k: 1,
threshold: DEFAULT_THRESHOLD,
device,
})
}
fn make_test_classifier_k2() -> Result<SemanticHintClassifier, InferenceError> {
let device = Device::Cpu;
let tokenizer = make_test_tokenizer()?;
let embeddings = make_test_token_embeddings(&device)?;
let norm = (0.1f32 * 0.1 + 0.9 * 0.9 + 0.1 * 0.1 + 0.0).sqrt();
let r2 = [0.1 / norm, 0.9 / norm, 0.1 / norm, 0.0 / norm];
#[rustfmt::skip]
let type_data: Vec<f32> = vec![
0.0, 1.0, 0.0, 0.0, r2[0], r2[1], r2[2], r2[3], 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, ];
let type_embeddings = Tensor::from_vec(type_data, (4, 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,
k: 2,
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_max_sim_picks_best_representative() {
let classifier = make_test_classifier_k2().unwrap();
let result = classifier.classify_header("email");
assert!(result.is_some(), "Expected match for 'email' with K=2");
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' with K=2");
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_zero_padded_rep_ignored() {
let classifier = make_test_classifier_k2().unwrap();
let result = classifier.classify_header("data");
assert!(
result.is_none(),
"Expected no match for 'data' at threshold 0.65, got {:?}",
result
);
}
#[test]
fn test_k_inferred_from_shape() {
let device = Device::Cpu;
let type_data_k1: Vec<f32> = vec![0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0];
let type_emb_k1 = Tensor::from_vec(type_data_k1, (2, 4), &device).unwrap();
let labels = [
"identity.person.email".to_string(),
"identity.person.phone_number".to_string(),
];
let k1 = type_emb_k1.dim(0).unwrap() / labels.len();
assert_eq!(k1, 1);
let type_data_k3: Vec<f32> = vec![0.0; 6 * 4]; let type_emb_k3 = Tensor::from_vec(type_data_k3, (6, 4), &device).unwrap();
let k3 = type_emb_k3.dim(0).unwrap() / labels.len();
assert_eq!(k3, 3);
let n_rows = 5;
let n_labels = labels.len();
assert_ne!(n_rows % n_labels, 0, "5 rows should not divide evenly by 2");
}
#[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"),
];
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", "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
);
}
let data_result = classifier.classify_header("data");
assert!(
data_result.is_some(),
"Expected borderline match for 'data' at 0.65 threshold"
);
}
#[test]
fn test_from_shared_matches_load() {
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 from_shared integration test: models/model2vec not found");
return;
}
let standalone = SemanticHintClassifier::load(&model_dir).unwrap();
let resources = Model2VecResources::load(&model_dir).unwrap();
let type_emb_bytes = std::fs::read(model_dir.join("type_embeddings.safetensors")).unwrap();
let label_bytes = std::fs::read(model_dir.join("label_index.json")).unwrap();
let shared =
SemanticHintClassifier::from_shared(&resources, &type_emb_bytes, &label_bytes).unwrap();
let test_headers = vec![
"email",
"phone_number",
"zip_code",
"data",
"foo",
"country",
];
for header in test_headers {
let r1 = standalone.classify_header(header);
let r2 = shared.classify_header(header);
match (&r1, &r2) {
(Some(a), Some(b)) => {
assert_eq!(
a.label, b.label,
"Label mismatch for '{}': standalone='{}' vs shared='{}'",
header, a.label, b.label
);
assert!(
(a.similarity - b.similarity).abs() < 1e-5,
"Similarity mismatch for '{}': {:.5} vs {:.5}",
header,
a.similarity,
b.similarity
);
}
(None, None) => {} _ => panic!(
"Mismatch for '{}': standalone={:?}, shared={:?}",
header, r1, r2
),
}
}
}
}