use anyhow::{Context, Result};
use ort::session::Session;
use ort::value::Value;
use parking_lot::Mutex;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use tokenizers::Tokenizer;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NerTag {
Outside,
BeginMisc,
InsideMisc,
BeginOrg,
InsideOrg,
BeginLoc,
InsideLoc,
BeginPerson,
InsidePerson,
}
impl NerTag {
fn from_index(idx: usize) -> Self {
match idx {
0 => NerTag::Outside,
1 => NerTag::BeginMisc,
2 => NerTag::InsideMisc,
3 => NerTag::BeginOrg,
4 => NerTag::InsideOrg,
5 => NerTag::BeginLoc,
6 => NerTag::InsideLoc,
7 => NerTag::BeginPerson,
8 => NerTag::InsidePerson,
_ => NerTag::Outside,
}
}
fn is_begin(&self) -> bool {
matches!(
self,
NerTag::BeginMisc | NerTag::BeginPerson | NerTag::BeginOrg | NerTag::BeginLoc
)
}
fn is_inside(&self) -> bool {
matches!(
self,
NerTag::InsideMisc | NerTag::InsidePerson | NerTag::InsideOrg | NerTag::InsideLoc
)
}
fn entity_type(&self) -> Option<NerEntityType> {
match self {
NerTag::BeginPerson | NerTag::InsidePerson => Some(NerEntityType::Person),
NerTag::BeginOrg | NerTag::InsideOrg => Some(NerEntityType::Organization),
NerTag::BeginLoc | NerTag::InsideLoc => Some(NerEntityType::Location),
NerTag::BeginMisc | NerTag::InsideMisc => Some(NerEntityType::Misc),
NerTag::Outside => None,
}
}
fn matches_type(&self, other: &NerTag) -> bool {
self.entity_type() == other.entity_type()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NerEntityType {
Person,
Organization,
Location,
Misc,
}
impl NerEntityType {
pub fn as_str(&self) -> &'static str {
match self {
NerEntityType::Person => "PER",
NerEntityType::Organization => "ORG",
NerEntityType::Location => "LOC",
NerEntityType::Misc => "MISC",
}
}
}
#[derive(Debug, Clone)]
pub struct NerEntity {
pub text: String,
pub entity_type: NerEntityType,
pub confidence: f32,
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone)]
pub struct NerConfig {
pub model_path: PathBuf,
pub tokenizer_path: PathBuf,
pub max_length: usize,
pub confidence_threshold: f32,
}
impl Default for NerConfig {
fn default() -> Self {
Self::from_env()
}
}
impl NerConfig {
pub fn from_env() -> Self {
let base_path = std::env::var("SHODH_NER_MODEL_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let candidates: Vec<Option<PathBuf>> = vec![
std::env::var("SHODH_PACKAGE_DIR")
.ok()
.map(|p| PathBuf::from(p).join("models/bert-tiny-ner")),
Some(PathBuf::from("./models/bert-tiny-ner")),
Some(PathBuf::from("../models/bert-tiny-ner")),
Some(super::downloader::get_ner_models_dir()),
dirs::data_dir().map(|p| p.join("shodh-memory/models/bert-tiny-ner")),
];
candidates
.into_iter()
.flatten()
.find(|p| p.join("model.onnx").exists())
.unwrap_or_else(super::downloader::get_ner_models_dir)
});
let confidence_threshold = std::env::var("SHODH_NER_CONFIDENCE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0.7);
Self {
model_path: base_path.join("model.onnx"),
tokenizer_path: base_path.join("tokenizer.json"),
max_length: 128,
confidence_threshold,
}
}
}
struct LazyNerModel {
session: Mutex<Session>,
tokenizer: Tokenizer,
}
impl LazyNerModel {
fn new(config: &NerConfig) -> Result<Self> {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
let default_threads = 1;
#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
let default_threads = 2;
let num_threads = std::env::var("SHODH_ONNX_THREADS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(default_threads);
tracing::info!(
"Loading BERT-NER model from {:?} with {} threads",
config.model_path,
num_threads
);
let builder = Session::builder()
.context("Failed to create NER session builder")?
.with_intra_threads(num_threads)
.context("Failed to set NER intra thread count")?
.with_inter_threads(1)
.context("Failed to set NER inter thread count")?;
let builder = builder
.with_intra_op_spinning(false)
.context("Failed to disable NER intra-op spinning")?
.with_inter_op_spinning(false)
.context("Failed to disable NER inter-op spinning")?;
let session = builder
.commit_from_file(&config.model_path)
.context("Failed to load NER ONNX model")?;
let tokenizer = Tokenizer::from_file(&config.tokenizer_path)
.map_err(|e| anyhow::anyhow!("Failed to load NER tokenizer: {e}"))?;
tracing::info!("BERT-NER model loaded successfully");
Ok(Self {
session: Mutex::new(session),
tokenizer,
})
}
}
const NER_CACHE_SIZE: u64 = 1000;
pub struct NeuralNer {
config: NerConfig,
lazy_model: OnceLock<Result<Arc<LazyNerModel>, String>>,
use_fallback: bool,
entity_extractor: OnceLock<crate::graph_memory::EntityExtractor>,
entity_cache: moka::sync::Cache<u64, Vec<NerEntity>>,
}
impl NeuralNer {
pub fn new(config: NerConfig) -> Result<Self> {
let model_available = config.model_path.exists() && config.tokenizer_path.exists();
let cache = moka::sync::Cache::builder()
.max_capacity(NER_CACHE_SIZE)
.time_to_live(std::time::Duration::from_secs(3600)) .build();
if !model_available {
tracing::warn!(
"NER model not found at {:?}. Using rule-based fallback.",
config.model_path
);
return Ok(Self {
config,
lazy_model: OnceLock::new(),
use_fallback: true,
entity_extractor: OnceLock::new(),
entity_cache: cache,
});
}
Ok(Self {
config,
lazy_model: OnceLock::new(),
use_fallback: false,
entity_extractor: OnceLock::new(),
entity_cache: cache,
})
}
pub fn new_fallback(config: NerConfig) -> Self {
Self {
config,
lazy_model: OnceLock::new(),
use_fallback: true,
entity_extractor: OnceLock::new(),
entity_cache: moka::sync::Cache::builder()
.max_capacity(NER_CACHE_SIZE)
.time_to_live(std::time::Duration::from_secs(3600))
.build(),
}
}
fn ensure_model_loaded(&self) -> Result<&Arc<LazyNerModel>> {
if self.use_fallback {
anyhow::bail!("NER model in fallback mode");
}
let result = self.lazy_model.get_or_init(|| {
LazyNerModel::new(&self.config)
.map(Arc::new)
.map_err(|e| e.to_string())
});
match result {
Ok(model) => Ok(model),
Err(e) => Err(anyhow::anyhow!("Failed to load NER model: {e}")),
}
}
pub fn is_fallback_mode(&self) -> bool {
self.use_fallback
}
fn cache_key(text: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
}
pub fn extract(&self, text: &str) -> Result<Vec<NerEntity>> {
if text.trim().is_empty() {
return Ok(Vec::new());
}
let cache_key = Self::cache_key(text);
if let Some(cached) = self.entity_cache.get(&cache_key) {
return Ok(cached);
}
let entities = if self.use_fallback {
self.extract_fallback(text)?
} else {
match self.extract_neural(text) {
Ok(entities) => entities,
Err(e) => {
tracing::warn!("Neural NER failed: {}. Using fallback.", e);
self.extract_fallback(text)?
}
}
};
self.entity_cache.insert(cache_key, entities.clone());
Ok(entities)
}
pub fn extract_batch(&self, texts: &[&str]) -> Result<Vec<Vec<NerEntity>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
let mut results = vec![Vec::new(); texts.len()];
let mut uncached_indices = Vec::new();
let mut uncached_texts = Vec::new();
for (i, &text) in texts.iter().enumerate() {
if text.trim().is_empty() {
continue;
}
let cache_key = Self::cache_key(text);
if let Some(cached) = self.entity_cache.get(&cache_key) {
results[i] = cached;
} else {
uncached_indices.push(i);
uncached_texts.push(text);
}
}
if !uncached_texts.is_empty() {
if self.use_fallback {
for (idx, text) in uncached_indices.iter().zip(uncached_texts.iter()) {
let entities = self.extract_fallback(text)?;
self.entity_cache
.insert(Self::cache_key(text), entities.clone());
results[*idx] = entities;
}
} else {
match self.extract_neural_batch(&uncached_texts) {
Ok(batch_results) => {
for ((idx, text), entities) in uncached_indices
.iter()
.zip(uncached_texts.iter())
.zip(batch_results.into_iter())
{
self.entity_cache
.insert(Self::cache_key(text), entities.clone());
results[*idx] = entities;
}
}
Err(e) => {
tracing::warn!("Batch NER failed: {}. Using fallback.", e);
for (idx, text) in uncached_indices.iter().zip(uncached_texts.iter()) {
let entities = self.extract_fallback(text)?;
self.entity_cache
.insert(Self::cache_key(text), entities.clone());
results[*idx] = entities;
}
}
}
}
}
Ok(results)
}
fn extract_neural_batch(&self, texts: &[&str]) -> Result<Vec<Vec<NerEntity>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
if texts.len() <= 2 {
let mut results = Vec::with_capacity(texts.len());
for text in texts {
results.push(self.extract_neural(text)?);
}
return Ok(results);
}
let model = self.ensure_model_loaded()?;
let max_length = self.config.max_length;
let batch_size = texts.len();
let mut all_encodings = Vec::with_capacity(batch_size);
for text in texts {
let encoding = model
.tokenizer
.encode(*text, true)
.map_err(|e| anyhow::anyhow!("NER batch tokenization failed: {e}"))?;
all_encodings.push(encoding);
}
let mut input_ids = vec![0i64; batch_size * max_length];
let mut attention_mask = vec![0i64; batch_size * max_length];
let token_type_ids = vec![0i64; batch_size * max_length];
for (batch_idx, encoding) in all_encodings.iter().enumerate() {
let tokens = encoding.get_ids();
let attention = encoding.get_attention_mask();
let base = batch_idx * max_length;
for (i, &token) in tokens.iter().take(max_length).enumerate() {
input_ids[base + i] = token as i64;
}
for (i, &mask) in attention.iter().take(max_length).enumerate() {
attention_mask[base + i] = mask as i64;
}
}
let input_ids_value = Value::from_array((vec![batch_size, max_length], input_ids))
.context("Failed to create batched input_ids tensor")?;
let attention_mask_value =
Value::from_array((vec![batch_size, max_length], attention_mask.clone()))
.context("Failed to create batched attention_mask tensor")?;
let token_type_ids_value =
Value::from_array((vec![batch_size, max_length], token_type_ids))
.context("Failed to create batched token_type_ids tensor")?;
let mut session = match model
.session
.try_lock_for(std::time::Duration::from_secs(30))
{
Some(guard) => guard,
None => {
tracing::warn!("NER batch session lock timeout after 30s, returning empty results");
crate::metrics::NER_LOCK_TIMEOUT_TOTAL.inc();
return Ok(vec![Vec::new(); texts.len()]);
}
};
let outputs = session
.run(ort::inputs![
"input_ids" => &input_ids_value,
"attention_mask" => &attention_mask_value,
"token_type_ids" => &token_type_ids_value,
])
.context("NER batch inference failed")?;
let output_tensor = outputs[0]
.try_extract_tensor::<f32>()
.context("Failed to extract NER batch output tensor")?;
let (_shape, logits) = output_tensor;
let num_labels = 9;
let mut all_entities = Vec::with_capacity(batch_size);
for (batch_idx, encoding) in all_encodings.iter().enumerate() {
let text = texts[batch_idx];
let offsets = encoding.get_offsets();
let tokens = encoding.get_ids();
let seq_len = tokens.len().min(max_length);
let batch_offset = batch_idx * max_length * num_labels;
let batch_attention = &attention_mask[batch_idx * max_length..];
let mut entities = Vec::new();
let mut current_entity: Option<(NerTag, Vec<usize>, f32)> = None;
#[allow(clippy::needless_range_loop)] for i in 0..seq_len {
if i == 0 || batch_attention[i] == 0 {
continue;
}
let start_idx = batch_offset + i * num_labels;
let token_logits = &logits[start_idx..start_idx + num_labels];
let Some((best_idx, best_prob)) = argmax_softmax(token_logits) else {
continue; };
let tag = NerTag::from_index(best_idx);
match (¤t_entity, tag.is_begin(), tag.is_inside()) {
(None, true, _) => {
current_entity = Some((tag, vec![i], best_prob));
}
(Some((prev_tag, _indices, _acc_prob)), _, true)
if tag.matches_type(prev_tag) =>
{
if let Some((prev_tag, mut indices, acc_prob)) = current_entity.take() {
indices.push(i);
current_entity = Some((prev_tag, indices, acc_prob + best_prob));
}
}
(Some((_prev_tag, _indices, _acc_prob)), _, _) => {
if let Some((prev_tag, indices, acc_prob)) = current_entity.take() {
if let Some(entity) =
self.build_entity(text, &prev_tag, &indices, acc_prob, offsets)
{
if entity.confidence >= self.config.confidence_threshold {
entities.push(entity);
}
}
}
if tag.is_begin() {
current_entity = Some((tag, vec![i], best_prob));
} else {
current_entity = None;
}
}
_ => {}
}
}
if let Some((tag, indices, acc_prob)) = current_entity {
if let Some(entity) = self.build_entity(text, &tag, &indices, acc_prob, offsets) {
if entity.confidence >= self.config.confidence_threshold {
entities.push(entity);
}
}
}
let entities = self.deduplicate_entities(entities);
all_entities.push(entities);
}
Ok(all_entities)
}
pub fn cache_stats(&self) -> (u64, u64) {
(self.entity_cache.entry_count(), NER_CACHE_SIZE)
}
pub fn clear_cache(&self) {
self.entity_cache.invalidate_all();
}
fn extract_neural(&self, text: &str) -> Result<Vec<NerEntity>> {
let model = self.ensure_model_loaded()?;
let mut session = match model
.session
.try_lock_for(std::time::Duration::from_secs(30))
{
Some(guard) => guard,
None => {
tracing::warn!("NER session lock timeout after 30s, returning empty");
crate::metrics::NER_LOCK_TIMEOUT_TOTAL.inc();
return Ok(Vec::new());
}
};
let encoding = model
.tokenizer
.encode(text, true)
.map_err(|e| anyhow::anyhow!("NER tokenization failed: {e}"))?;
let tokens = encoding.get_ids();
let attention_mask = encoding.get_attention_mask();
let offsets = encoding.get_offsets();
let max_length = self.config.max_length;
let mut input_ids = vec![0i64; max_length];
let mut attention = vec![0i64; max_length];
for (i, &token) in tokens.iter().take(max_length).enumerate() {
input_ids[i] = token as i64;
}
for (i, &mask) in attention_mask.iter().take(max_length).enumerate() {
attention[i] = mask as i64;
}
let token_type_ids = vec![0i64; max_length];
let input_ids_value = Value::from_array((vec![1, max_length], input_ids))
.context("Failed to create input_ids tensor")?;
let attention_mask_value = Value::from_array((vec![1, max_length], attention.clone()))
.context("Failed to create attention_mask tensor")?;
let token_type_ids_value = Value::from_array((vec![1, max_length], token_type_ids))
.context("Failed to create token_type_ids tensor")?;
let outputs = session
.run(ort::inputs![
"input_ids" => &input_ids_value,
"attention_mask" => &attention_mask_value,
"token_type_ids" => &token_type_ids_value,
])
.context("NER inference failed")?;
let output_tensor = outputs[0]
.try_extract_tensor::<f32>()
.context("Failed to extract NER output tensor")?;
let (_shape, logits) = output_tensor;
let num_labels = 9; let seq_len = tokens.len().min(max_length);
let mut entities = Vec::new();
let mut current_entity: Option<(NerTag, Vec<usize>, f32)> = None;
#[allow(clippy::needless_range_loop)] for i in 0..seq_len {
if i == 0 || attention[i] == 0 {
continue;
}
let start_idx = i * num_labels;
let token_logits = &logits[start_idx..start_idx + num_labels];
let Some((best_idx, best_prob)) = argmax_softmax(token_logits) else {
continue; };
let tag = NerTag::from_index(best_idx);
match (¤t_entity, tag.is_begin(), tag.is_inside()) {
(None, true, _) => {
current_entity = Some((tag, vec![i], best_prob));
}
(Some((prev_tag, _indices, _acc_prob)), _, true) if tag.matches_type(prev_tag) => {
if let Some((prev_tag, mut indices, acc_prob)) = current_entity.take() {
indices.push(i);
current_entity = Some((prev_tag, indices, acc_prob + best_prob));
}
}
(Some((_prev_tag, _indices, _acc_prob)), _, _) => {
if let Some((prev_tag, indices, acc_prob)) = current_entity.take() {
if let Some(entity) =
self.build_entity(text, &prev_tag, &indices, acc_prob, offsets)
{
if entity.confidence >= self.config.confidence_threshold {
entities.push(entity);
}
}
}
if tag.is_begin() {
current_entity = Some((tag, vec![i], best_prob));
} else {
current_entity = None;
}
}
_ => {}
}
}
if let Some((tag, indices, acc_prob)) = current_entity {
if let Some(entity) = self.build_entity(text, &tag, &indices, acc_prob, offsets) {
if entity.confidence >= self.config.confidence_threshold {
entities.push(entity);
}
}
}
let entities = self.deduplicate_entities(entities);
Ok(entities)
}
fn build_entity(
&self,
text: &str,
tag: &NerTag,
token_indices: &[usize],
accumulated_prob: f32,
offsets: &[(usize, usize)],
) -> Option<NerEntity> {
if token_indices.is_empty() {
return None;
}
let entity_type = tag.entity_type()?;
let first_idx = token_indices[0];
let last_idx = token_indices[token_indices.len() - 1];
if first_idx >= offsets.len() || last_idx >= offsets.len() {
return None;
}
let start = offsets[first_idx].0;
let end = offsets[last_idx].1;
if start >= end || end > text.len() {
return None;
}
let entity_text = text[start..end].trim().to_string();
if entity_text.is_empty() {
return None;
}
let confidence = accumulated_prob / token_indices.len() as f32;
Some(NerEntity {
text: entity_text,
entity_type,
confidence,
start,
end,
})
}
fn deduplicate_entities(&self, mut entities: Vec<NerEntity>) -> Vec<NerEntity> {
if entities.len() <= 1 {
return entities;
}
entities.sort_by(|a, b| {
a.start
.cmp(&b.start)
.then_with(|| (b.end - b.start).cmp(&(a.end - a.start)))
});
let mut result = Vec::new();
let mut seen_spans: HashSet<(usize, usize)> = HashSet::new();
for entity in entities {
let overlaps = seen_spans
.iter()
.any(|&(s, e)| entity.start < e && entity.end > s);
if !overlaps {
seen_spans.insert((entity.start, entity.end));
result.push(entity);
}
}
result
}
fn extract_fallback(&self, text: &str) -> Result<Vec<NerEntity>> {
use crate::graph_memory::{EntityExtractor, EntityLabel};
let extractor = self.entity_extractor.get_or_init(EntityExtractor::new);
let extracted = extractor.extract_with_salience(text);
let entities: Vec<NerEntity> = extracted
.into_iter()
.map(|e| {
let entity_type = match e.label {
EntityLabel::Person => NerEntityType::Person,
EntityLabel::Organization | EntityLabel::Team => NerEntityType::Organization,
EntityLabel::Location | EntityLabel::Environment => NerEntityType::Location,
EntityLabel::Technology
| EntityLabel::Concept
| EntityLabel::Event
| EntityLabel::Date
| EntityLabel::Product
| EntityLabel::Skill
| EntityLabel::Keyword
| EntityLabel::Project
| EntityLabel::Task
| EntityLabel::Document
| EntityLabel::Repository
| EntityLabel::Service
| EntityLabel::Database
| EntityLabel::Metric
| EntityLabel::Configuration
| EntityLabel::Pipeline
| EntityLabel::Role
| EntityLabel::Module
| EntityLabel::Other(_) => NerEntityType::Misc,
};
let confidence = (e.base_salience * 0.9).min(0.85);
let name_len = e.name.len();
let (start, end) = text
.char_indices()
.find(|&(i, _)| {
text[i..]
.get(..name_len)
.is_some_and(|slice| slice.eq_ignore_ascii_case(&e.name))
})
.map(|(pos, _)| (pos, pos + name_len))
.unwrap_or((0, name_len.min(text.len())));
NerEntity {
text: e.name,
entity_type,
confidence,
start,
end,
}
})
.collect();
Ok(entities)
}
}
pub fn argmax_softmax(logits: &[f32]) -> Option<(usize, f32)> {
if logits.is_empty() {
return None;
}
let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let exp_sum: f32 = logits.iter().map(|x| (x - max_logit).exp()).sum();
logits
.iter()
.enumerate()
.max_by(|a, b| a.1.total_cmp(b.1))
.map(|(idx, &val)| (idx, (val - max_logit).exp() / exp_sum))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ner_tag_from_index() {
assert_eq!(NerTag::from_index(0), NerTag::Outside);
assert_eq!(NerTag::from_index(1), NerTag::BeginMisc);
assert_eq!(NerTag::from_index(2), NerTag::InsideMisc);
assert_eq!(NerTag::from_index(3), NerTag::BeginOrg);
assert_eq!(NerTag::from_index(4), NerTag::InsideOrg);
assert_eq!(NerTag::from_index(5), NerTag::BeginLoc);
assert_eq!(NerTag::from_index(6), NerTag::InsideLoc);
assert_eq!(NerTag::from_index(7), NerTag::BeginPerson);
assert_eq!(NerTag::from_index(8), NerTag::InsidePerson);
assert_eq!(NerTag::from_index(99), NerTag::Outside);
}
#[test]
fn test_tag_is_begin() {
assert!(NerTag::BeginPerson.is_begin());
assert!(NerTag::BeginOrg.is_begin());
assert!(NerTag::BeginLoc.is_begin());
assert!(NerTag::BeginMisc.is_begin());
assert!(!NerTag::InsidePerson.is_begin());
assert!(!NerTag::Outside.is_begin());
}
#[test]
fn test_tag_is_inside() {
assert!(NerTag::InsidePerson.is_inside());
assert!(NerTag::InsideOrg.is_inside());
assert!(NerTag::InsideLoc.is_inside());
assert!(NerTag::InsideMisc.is_inside());
assert!(!NerTag::BeginPerson.is_inside());
assert!(!NerTag::Outside.is_inside());
}
#[test]
fn test_tag_entity_type() {
assert_eq!(
NerTag::BeginPerson.entity_type(),
Some(NerEntityType::Person)
);
assert_eq!(
NerTag::InsidePerson.entity_type(),
Some(NerEntityType::Person)
);
assert_eq!(
NerTag::BeginOrg.entity_type(),
Some(NerEntityType::Organization)
);
assert_eq!(
NerTag::BeginLoc.entity_type(),
Some(NerEntityType::Location)
);
assert_eq!(NerTag::BeginMisc.entity_type(), Some(NerEntityType::Misc));
assert_eq!(NerTag::Outside.entity_type(), None);
}
#[test]
fn test_tag_matching() {
let b_per = NerTag::BeginPerson;
let i_per = NerTag::InsidePerson;
let b_org = NerTag::BeginOrg;
let i_org = NerTag::InsideOrg;
assert!(b_per.matches_type(&i_per));
assert!(b_org.matches_type(&i_org));
assert!(!b_per.matches_type(&b_org));
assert!(!i_per.matches_type(&i_org));
}
#[test]
fn test_entity_type_as_str() {
assert_eq!(NerEntityType::Person.as_str(), "PER");
assert_eq!(NerEntityType::Organization.as_str(), "ORG");
assert_eq!(NerEntityType::Location.as_str(), "LOC");
assert_eq!(NerEntityType::Misc.as_str(), "MISC");
}
fn softmax(logits: &[f32]) -> Vec<f32> {
let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let exp_sum: f32 = logits.iter().map(|x| (x - max_logit).exp()).sum();
logits
.iter()
.map(|x| (x - max_logit).exp() / exp_sum)
.collect()
}
#[test]
fn test_softmax_basic() {
let logits = vec![1.0, 2.0, 3.0];
let probs = softmax(&logits);
let sum: f32 = probs.iter().sum();
assert!((sum - 1.0).abs() < 1e-5);
assert!(probs[2] > probs[1]);
assert!(probs[1] > probs[0]);
}
#[test]
fn test_softmax_uniform() {
let logits = vec![1.0, 1.0, 1.0];
let probs = softmax(&logits);
for prob in &probs {
assert!((*prob - 1.0 / 3.0).abs() < 1e-5);
}
}
#[test]
fn test_softmax_large_values() {
let logits = vec![100.0, 101.0, 102.0];
let probs = softmax(&logits);
let sum: f32 = probs.iter().sum();
assert!((sum - 1.0).abs() < 1e-5);
assert!(probs[2] > probs[1]);
}
#[test]
fn test_softmax_negative_values() {
let logits = vec![-1.0, 0.0, 1.0];
let probs = softmax(&logits);
let sum: f32 = probs.iter().sum();
assert!((sum - 1.0).abs() < 1e-5);
assert!(probs[2] > probs[1]);
assert!(probs[1] > probs[0]);
}
#[test]
fn test_ner_config_default() {
let config = NerConfig::default();
assert_eq!(config.max_length, 128); assert!((config.confidence_threshold - 0.7).abs() < 1e-5);
}
#[test]
fn test_fallback_mode_detection() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
assert!(ner.is_fallback_mode());
}
#[test]
fn test_fallback_extraction_organizations() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
let test_cases = vec![
(
"Microsoft is a company",
"Microsoft",
NerEntityType::Organization,
),
("I work at Google", "Google", NerEntityType::Organization),
(
"Apple released a new product",
"Apple",
NerEntityType::Organization,
),
(
"Tata group is expanding",
"Tata",
NerEntityType::Organization,
),
(
"Infosys reported earnings",
"Infosys",
NerEntityType::Organization,
),
];
for (text, expected_entity, expected_type) in test_cases {
let entities = ner.extract(text).unwrap();
let found = entities.iter().find(|e| e.text == expected_entity);
assert!(found.is_some(), "Should find {expected_entity} in '{text}'");
assert_eq!(
found.unwrap().entity_type,
expected_type,
"Wrong type for {expected_entity} in '{text}'"
);
}
}
#[test]
fn test_fallback_extraction_locations() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
let test_cases = vec![
(
"The office is in Seattle",
"Seattle",
NerEntityType::Location,
),
(
"I visited Mumbai last week",
"Mumbai",
NerEntityType::Location,
),
("Tokyo is beautiful", "Tokyo", NerEntityType::Location),
("Moving to Bangalore", "Bangalore", NerEntityType::Location),
("India is growing", "India", NerEntityType::Location),
];
for (text, expected_entity, expected_type) in test_cases {
let entities = ner.extract(text).unwrap();
let found = entities.iter().find(|e| e.text == expected_entity);
assert!(found.is_some(), "Should find {expected_entity} in '{text}'");
assert_eq!(
found.unwrap().entity_type,
expected_type,
"Wrong type for {expected_entity} in '{text}'"
);
}
}
#[test]
fn test_fallback_extraction_mixed() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
let entities = ner
.extract("Microsoft is headquartered in Seattle")
.unwrap();
let microsoft = entities.iter().find(|e| e.text == "Microsoft");
let seattle = entities.iter().find(|e| e.text == "Seattle");
assert!(microsoft.is_some());
assert!(seattle.is_some());
assert_eq!(microsoft.unwrap().entity_type, NerEntityType::Organization);
assert_eq!(seattle.unwrap().entity_type, NerEntityType::Location);
}
#[test]
fn test_fallback_extraction_empty_text() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
let entities = ner.extract("").unwrap();
assert!(entities.is_empty());
}
#[test]
fn test_fallback_extraction_whitespace_only() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
let entities = ner.extract(" \t\n ").unwrap();
assert!(entities.is_empty());
}
#[test]
fn test_fallback_extraction_stop_words_only() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
let entities = ner.extract("the a an and or is are was were").unwrap();
assert!(
entities.is_empty(),
"Expected no entities from stop words but got: {entities:?}"
);
}
#[test]
fn test_fallback_deduplication() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
let entities = ner
.extract("Microsoft partnered with Microsoft Azure")
.unwrap();
let microsoft_count = entities.iter().filter(|e| e.text == "Microsoft").count();
assert_eq!(microsoft_count, 1, "Microsoft should appear only once");
}
#[test]
fn test_fallback_confidence_scores() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
let entities = ner.extract("Microsoft Google Apple").unwrap();
for entity in &entities {
assert!(
entity.confidence >= 0.5 && entity.confidence <= 1.0,
"Confidence {} out of expected range",
entity.confidence
);
}
}
#[test]
fn test_ner_entity_clone() {
let entity = NerEntity {
text: "Microsoft".to_string(),
entity_type: NerEntityType::Organization,
confidence: 0.95,
start: 0,
end: 9,
};
let cloned = entity.clone();
assert_eq!(cloned.text, entity.text);
assert_eq!(cloned.entity_type, entity.entity_type);
assert!((cloned.confidence - entity.confidence).abs() < 1e-5);
}
#[test]
fn test_single_character_words() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
let entities = ner.extract("I A B C").unwrap();
assert!(entities.is_empty() || entities.iter().all(|e| e.text.len() >= 2));
}
#[test]
fn test_punctuation_handling() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
let entities = ner.extract("Microsoft, Google, and Apple!").unwrap();
for entity in &entities {
assert!(!entity.text.contains(','));
assert!(!entity.text.contains('!'));
}
}
#[test]
fn test_indian_companies() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
let indian_companies = vec!["Flipkart", "Zomato", "Swiggy", "Paytm"];
for company in indian_companies {
let entities = ner.extract(&format!("{company} is growing")).unwrap();
let found = entities.iter().find(|e| e.text == company);
assert!(found.is_some(), "Should find Indian company: {company}");
}
}
#[test]
fn test_indian_cities() {
let config = NerConfig {
model_path: PathBuf::from("nonexistent.onnx"),
tokenizer_path: PathBuf::from("nonexistent.json"),
max_length: 128,
confidence_threshold: 0.5,
};
let ner = NeuralNer::new_fallback(config);
let indian_cities = vec!["Mumbai", "Delhi", "Bangalore", "Chennai", "Hyderabad"];
for city in indian_cities {
let entities = ner.extract(&format!("Office in {city}")).unwrap();
let found = entities.iter().find(|e| e.text == city);
assert!(found.is_some(), "Should find Indian city: {city}");
assert_eq!(
found.unwrap().entity_type,
NerEntityType::Location,
"{city} should be Location"
);
}
}
}