use fst::Set;
use regex::Regex;
use std::sync::LazyLock;
use crate::text_util::{min_suggestion_distance, safe_slice};
static NAME_FST_BYTES: &[u8] = include_bytes!("../dictionaries/names/names.fst");
static GAZETTEER: LazyLock<Option<Set<&'static [u8]>>> =
LazyLock::new(|| Set::new(NAME_FST_BYTES).ok());
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Aggressiveness {
Conservative,
#[default]
Balanced,
Aggressive,
}
impl Aggressiveness {
const fn threshold(self) -> f32 {
match self {
Self::Conservative => 4.0,
Self::Balanced => 3.0,
Self::Aggressive => 2.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NameSignal {
Gazetteer,
Orphan,
NoSuggestions,
Repetition,
Context,
Shape,
}
impl NameSignal {
const fn weight(self) -> f32 {
match self {
Self::Gazetteer | Self::Orphan => 2.0,
Self::NoSuggestions | Self::Context => 1.5,
Self::Repetition | Self::Shape => 1.0,
}
}
#[must_use]
pub const fn tag(self) -> &'static str {
match self {
Self::Gazetteer => "gazetteer",
Self::Orphan => "orphan",
Self::NoSuggestions => "no-suggestions",
Self::Repetition => "repetition",
Self::Context => "context",
Self::Shape => "shape",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct NameVerdict {
pub is_name: bool,
pub score: f32,
pub signals: Vec<NameSignal>,
}
impl NameVerdict {
#[must_use]
pub fn signal_tags(&self) -> String {
self.signals
.iter()
.map(|s| s.tag())
.collect::<Vec<_>>()
.join(",")
}
}
pub struct NameQuery<'a> {
pub token: &'a str,
pub text: &'a str,
pub start_byte: usize,
pub end_byte: usize,
pub suggestions: &'a [String],
}
static HONORIFIC_BEFORE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix)
\b(
mr | mrs | ms | miss | dr | prof(essor)? | sir | dame | lord | lady |
rev | hon | capt | sgt | st |
herr | frau | fr | fraeulein | fräulein |
monsieur | madame | mme | mlle | m |
senor | senora | señor | señora | sr | sra | srta |
dott | ing
)\.?\s+$",
)
.expect("valid honorific pattern")
});
static SALUTATION_BEFORE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix)\b(dear|hi|hello|hey|attn|regards|sincerely|cc|by|von|van|de|del|della|der)\s+$",
)
.expect("valid salutation pattern")
});
static CITATION_AFTER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?ix)^( ['’]s\b | \s+et\s+al\b | \s*,\s*\d{4}\b | \s+\(\d{4}\) )")
.expect("valid citation pattern")
});
static CAPITALISED_AFTER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s+\p{Lu}\p{Ll}+").expect("valid capitalised pattern"));
pub struct NameFilter {
aggressiveness: Aggressiveness,
language: String,
}
impl NameFilter {
#[must_use]
pub fn new(aggressiveness: Aggressiveness, language: &str) -> Self {
Self {
aggressiveness,
language: language.to_ascii_lowercase(),
}
}
fn capitalisation_is_informative(&self) -> bool {
!self.language.starts_with("de")
}
#[must_use]
pub fn evaluate(&self, query: &NameQuery<'_>) -> NameVerdict {
let mut signals = Vec::new();
if gazetteer_contains(query.token) {
signals.push(NameSignal::Gazetteer);
}
match min_suggestion_distance(query.token, query.suggestions) {
None => signals.push(NameSignal::NoSuggestions),
Some(distance) if distance >= 3 => signals.push(NameSignal::Orphan),
Some(_) => {}
}
if occurrences(query.text, query.token) > 1 {
signals.push(NameSignal::Repetition);
}
if self.has_name_context(query) {
signals.push(NameSignal::Context);
}
if self.capitalisation_is_informative()
&& is_capitalised(query.token)
&& !starts_sentence(query.text, query.start_byte)
{
signals.push(NameSignal::Shape);
}
let score: f32 = signals.iter().map(|s| s.weight()).sum();
let is_name = signals.len() >= 2 && score >= self.aggressiveness.threshold();
NameVerdict {
is_name,
score,
signals,
}
}
fn has_name_context(&self, query: &NameQuery<'_>) -> bool {
let before = preceding_window(query.text, query.start_byte);
let after = following_window(query.text, query.end_byte);
HONORIFIC_BEFORE.is_match(before)
|| SALUTATION_BEFORE.is_match(before)
|| CITATION_AFTER.is_match(after)
|| (self.capitalisation_is_informative()
&& is_capitalised(query.token)
&& CAPITALISED_AFTER.is_match(after))
}
}
fn gazetteer_contains(token: &str) -> bool {
let Some(set) = GAZETTEER.as_ref() else {
return false;
};
let lowered = token.to_lowercase();
morphological_bases(&lowered)
.into_iter()
.any(|candidate| set.contains(candidate.as_bytes()))
}
fn morphological_bases(lowered: &str) -> Vec<String> {
let mut bases = vec![lowered.to_string()];
for possessive in ["'s", "\u{2019}s"] {
if let Some(stem) = lowered.strip_suffix(possessive)
&& stem.len() >= 2
{
bases.push(stem.to_string());
}
}
for suffix in ["s", "en", "n"] {
if let Some(stem) = lowered.strip_suffix(suffix)
&& stem.len() >= 3
{
bases.push(stem.to_string());
}
}
bases
}
fn occurrences(text: &str, token: &str) -> usize {
if token.is_empty() {
return 0;
}
let mut count = 0;
let mut cursor = 0;
while let Some(found) = text[cursor..].find(token) {
let start = cursor + found;
let end = start + token.len();
let before_ok = start == 0
|| !text[..start]
.chars()
.next_back()
.is_some_and(char::is_alphanumeric);
let after_ok = end >= text.len()
|| !text[end..]
.chars()
.next()
.is_some_and(char::is_alphanumeric);
if before_ok && after_ok {
count += 1;
if count > 1 {
return count;
}
}
cursor = end.max(start + 1);
if cursor >= text.len() {
break;
}
}
count
}
fn is_capitalised(token: &str) -> bool {
token.chars().next().is_some_and(char::is_uppercase)
}
fn starts_sentence(text: &str, start: usize) -> bool {
let preceding = text[..start.min(text.len())].trim_end();
preceding.chars().next_back().is_none_or(|c| {
matches!(
c,
'.' | '!' | '?' | ':' | ';' | '\n' | '"' | '\'' | '(' | '['
)
})
}
const WINDOW: usize = 48;
fn preceding_window(text: &str, start: usize) -> &str {
safe_slice(text, start.saturating_sub(WINDOW), start)
}
fn following_window(text: &str, end: usize) -> &str {
safe_slice(text, end, end + WINDOW)
}
#[cfg(test)]
mod tests;