use candle_core::{Device, Tensor, DType};
use candle_nn::VarBuilder;
use candle_transformers::models::bert::{BertModel, Config};
use tokenizers::Tokenizer;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
pub struct Candidate {
pub label: String,
pub iri: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct NerResult {
pub label: String,
pub iri: String,
pub similarity: f32,
}
pub struct NerEngine {
model: BertModel,
tokenizer: Tokenizer,
}
impl NerEngine {
pub fn from_bytes(
weights: &[u8],
tokenizer_json: &str,
config_json: &str,
) -> Result<Self, Box<dyn std::error::Error>> {
let device = Device::Cpu;
let tokenizer = Tokenizer::from_bytes(tokenizer_json.as_bytes())
.map_err(|e| format!("tokenizer: {}", e))?;
let config: Config = serde_json::from_str(config_json)?;
let vb = VarBuilder::from_buffered_safetensors(
weights.to_vec(),
DType::F32,
&device,
)?;
let model = BertModel::load(vb, &config)?;
Ok(Self { model, tokenizer })
}
#[cfg(not(target_arch = "wasm32"))]
pub fn from_hf(model_id: &str) -> Result<Self, Box<dyn std::error::Error>> {
let api = hf_hub::api::sync::Api::new()?;
let repo = api.model(model_id.to_string());
let tokenizer_path = repo.get("tokenizer.json")?;
let tokenizer_json = std::fs::read_to_string(tokenizer_path)?;
let config_path = repo.get("config.json")?;
let config_json = std::fs::read_to_string(config_path)?;
let weights_path = repo.get("model.safetensors")?;
let weights = std::fs::read(weights_path)?;
Self::from_bytes(&weights, &tokenizer_json, &config_json)
}
pub fn encode(&self, text: &str) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
let encoding = self.tokenizer.encode(text, true)
.map_err(|e| format!("tokenize: {}", e))?;
let ids = encoding.get_ids();
let token_ids = Tensor::new(ids, &Device::Cpu)?.unsqueeze(0)?;
let type_ids = token_ids.zeros_like()?;
let embeddings = self.model.forward(&token_ids, &type_ids, None)?;
let (_, seq_len, _) = embeddings.dims3()?;
let mean = (embeddings.sum(1)? / (seq_len as f64))?.squeeze(0)?;
Ok(mean.to_vec1::<f32>()?)
}
pub fn zero_shot_ner(
&self,
text: &str,
candidates: &[Candidate],
threshold: f32,
) -> Result<Vec<NerResult>, Box<dyn std::error::Error>> {
let text_emb = self.encode(text)?;
let mut results: Vec<NerResult> = candidates.iter()
.map(|c| {
let label_emb = self.encode(&c.label)?;
let sim = cosine_similarity(&text_emb, &label_emb);
Ok(NerResult {
label: c.label.clone(),
iri: c.iri.clone(),
similarity: sim,
})
})
.collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?;
results.sort_by(|a, b| b.similarity.partial_cmp(&a.similarity).unwrap());
if threshold > 0.0 {
results.retain(|r| r.similarity >= threshold);
}
Ok(results)
}
}
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 { return 0.0; }
dot / (norm_a * norm_b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_zero_shot_ner_medical() {
let engine = match NerEngine::from_hf("sentence-transformers/all-MiniLM-L6-v2") {
Ok(e) => e,
Err(e) => { eprintln!("SKIP NER test: {}", e); return; }
};
let text = "The patient presents with acute myocardial infarction and requires immediate percutaneous coronary intervention.";
let candidates = vec![
Candidate { label: "myocardial infarction".into(), iri: "http://snomed.info/id/22298006".into() },
Candidate { label: "percutaneous coronary intervention".into(), iri: "http://snomed.info/id/415070008".into() },
Candidate { label: "diabetes mellitus".into(), iri: "http://snomed.info/id/73211009".into() },
Candidate { label: "fracture".into(), iri: "http://snomed.info/id/125605004".into() },
];
let results = engine.zero_shot_ner(text, &candidates, 0.0).unwrap();
eprintln!("NER results:");
for r in &results {
eprintln!(" {:.4} {} ({})", r.similarity, r.label, r.iri);
}
assert!(results[0].label.contains("coronary") || results[0].label.contains("myocardial"),
"Top match should be a relevant medical term, got: {}", results[0].label);
assert!(results[0].similarity > 0.85,
"Top similarity should be > 0.85, got: {}", results[0].similarity);
let filtered = engine.zero_shot_ner(text, &candidates, 0.9).unwrap();
assert!(filtered.len() <= 2, "Only top matches should survive 0.9 threshold");
}
#[test]
#[ignore]
#[cfg(not(target_arch = "wasm32"))]
fn test_ner_latency() {
let engine = match NerEngine::from_hf("sentence-transformers/all-MiniLM-L6-v2") {
Ok(e) => e,
Err(e) => { eprintln!("SKIP latency test: {}", e); return; }
};
let _ = engine.encode("warmup");
let text = "Chronic obstructive pulmonary disease with acute exacerbation requiring hospitalization.";
let candidates = vec![
Candidate { label: "COPD".into(), iri: "http://snomed.info/id/13645005".into() },
Candidate { label: "asthma".into(), iri: "http://snomed.info/id/195967001".into() },
Candidate { label: "pneumonia".into(), iri: "http://snomed.info/id/233604007".into() },
];
let start = std::time::Instant::now();
let results = engine.zero_shot_ner(text, &candidates, 0.0).unwrap();
let elapsed = start.elapsed();
eprintln!("NER inference latency: {:?} for {} candidates (after warmup)", elapsed, candidates.len());
let gate_ms: u128 = if cfg!(debug_assertions) { 30000 } else { 2000 };
assert!(elapsed.as_millis() < gate_ms,
"NER inference should complete in < {}ms, took {:?}", gate_ms, elapsed);
assert!(!results.is_empty());
}
}