1use fst::Set;
29use regex::Regex;
30use std::sync::LazyLock;
31
32static NAME_FST_BYTES: &[u8] = include_bytes!("../dictionaries/names/names.fst");
36
37static GAZETTEER: LazyLock<Option<Set<&'static [u8]>>> =
38 LazyLock::new(|| Set::new(NAME_FST_BYTES).ok());
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Aggressiveness {
44 Conservative,
46 #[default]
48 Balanced,
49 Aggressive,
51}
52
53impl Aggressiveness {
54 const fn threshold(self) -> f32 {
55 match self {
56 Self::Conservative => 4.0,
57 Self::Balanced => 3.0,
58 Self::Aggressive => 2.0,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum NameSignal {
66 Gazetteer,
68 Orphan,
70 NoSuggestions,
72 Repetition,
74 Context,
76 Shape,
78}
79
80impl NameSignal {
81 const fn weight(self) -> f32 {
82 match self {
83 Self::Gazetteer | Self::Orphan => 2.0,
84 Self::NoSuggestions | Self::Context => 1.5,
85 Self::Repetition | Self::Shape => 1.0,
86 }
87 }
88
89 #[must_use]
91 pub const fn tag(self) -> &'static str {
92 match self {
93 Self::Gazetteer => "gazetteer",
94 Self::Orphan => "orphan",
95 Self::NoSuggestions => "no-suggestions",
96 Self::Repetition => "repetition",
97 Self::Context => "context",
98 Self::Shape => "shape",
99 }
100 }
101}
102
103#[derive(Debug, Clone, Default)]
105pub struct NameVerdict {
106 pub is_name: bool,
107 pub score: f32,
108 pub signals: Vec<NameSignal>,
109}
110
111impl NameVerdict {
112 #[must_use]
114 pub fn signal_tags(&self) -> String {
115 self.signals
116 .iter()
117 .map(|s| s.tag())
118 .collect::<Vec<_>>()
119 .join(",")
120 }
121}
122
123pub struct NameQuery<'a> {
125 pub token: &'a str,
127 pub text: &'a str,
129 pub start_byte: usize,
131 pub end_byte: usize,
133 pub suggestions: &'a [String],
135}
136
137static HONORIFIC_BEFORE: LazyLock<Regex> = LazyLock::new(|| {
140 Regex::new(
141 r"(?ix)
142 \b(
143 mr | mrs | ms | miss | dr | prof(essor)? | sir | dame | lord | lady |
144 rev | hon | capt | sgt | st |
145 herr | frau | fr | fraeulein | fräulein |
146 monsieur | madame | mme | mlle | m |
147 senor | senora | señor | señora | sr | sra | srta |
148 dott | ing
149 )\.?\s+$",
150 )
151 .expect("valid honorific pattern")
152});
153
154static SALUTATION_BEFORE: LazyLock<Regex> = LazyLock::new(|| {
156 Regex::new(
157 r"(?ix)\b(dear|hi|hello|hey|attn|regards|sincerely|cc|by|von|van|de|del|della|der)\s+$",
158 )
159 .expect("valid salutation pattern")
160});
161
162static CITATION_AFTER: LazyLock<Regex> = LazyLock::new(|| {
164 Regex::new(r"(?ix)^( ['’]s\b | \s+et\s+al\b | \s*,\s*\d{4}\b | \s+\(\d{4}\) )")
165 .expect("valid citation pattern")
166});
167
168static CAPITALISED_AFTER: LazyLock<Regex> =
170 LazyLock::new(|| Regex::new(r"^\s+\p{Lu}\p{Ll}+").expect("valid capitalised pattern"));
171
172pub struct NameFilter {
174 aggressiveness: Aggressiveness,
175 language: String,
177}
178
179impl NameFilter {
180 #[must_use]
181 pub fn new(aggressiveness: Aggressiveness, language: &str) -> Self {
182 Self {
183 aggressiveness,
184 language: language.to_ascii_lowercase(),
185 }
186 }
187
188 fn capitalisation_is_informative(&self) -> bool {
194 !self.language.starts_with("de")
195 }
196
197 #[must_use]
199 pub fn evaluate(&self, query: &NameQuery<'_>) -> NameVerdict {
200 let mut signals = Vec::new();
201
202 if gazetteer_contains(query.token) {
203 signals.push(NameSignal::Gazetteer);
204 }
205
206 match min_suggestion_distance(query.token, query.suggestions) {
207 None => signals.push(NameSignal::NoSuggestions),
208 Some(distance) if distance >= 3 => signals.push(NameSignal::Orphan),
209 Some(_) => {}
210 }
211
212 if occurrences(query.text, query.token) > 1 {
213 signals.push(NameSignal::Repetition);
214 }
215
216 if self.has_name_context(query) {
217 signals.push(NameSignal::Context);
218 }
219
220 if self.capitalisation_is_informative()
221 && is_capitalised(query.token)
222 && !starts_sentence(query.text, query.start_byte)
223 {
224 signals.push(NameSignal::Shape);
225 }
226
227 let score: f32 = signals.iter().map(|s| s.weight()).sum();
228 let is_name = signals.len() >= 2 && score >= self.aggressiveness.threshold();
232
233 NameVerdict {
234 is_name,
235 score,
236 signals,
237 }
238 }
239
240 fn has_name_context(&self, query: &NameQuery<'_>) -> bool {
241 let before = preceding_window(query.text, query.start_byte);
242 let after = following_window(query.text, query.end_byte);
243
244 HONORIFIC_BEFORE.is_match(before)
245 || SALUTATION_BEFORE.is_match(before)
246 || CITATION_AFTER.is_match(after)
247 || (self.capitalisation_is_informative()
248 && is_capitalised(query.token)
249 && CAPITALISED_AFTER.is_match(after))
250 }
251}
252
253fn gazetteer_contains(token: &str) -> bool {
255 let Some(set) = GAZETTEER.as_ref() else {
256 return false;
257 };
258 let lowered = token.to_lowercase();
259 morphological_bases(&lowered)
260 .into_iter()
261 .any(|candidate| set.contains(candidate.as_bytes()))
262}
263
264fn morphological_bases(lowered: &str) -> Vec<String> {
269 let mut bases = vec![lowered.to_string()];
270
271 for possessive in ["'s", "\u{2019}s"] {
272 if let Some(stem) = lowered.strip_suffix(possessive)
273 && stem.len() >= 2
274 {
275 bases.push(stem.to_string());
276 }
277 }
278 for suffix in ["s", "en", "n"] {
280 if let Some(stem) = lowered.strip_suffix(suffix)
281 && stem.len() >= 3
282 {
283 bases.push(stem.to_string());
284 }
285 }
286
287 bases
288}
289
290fn min_suggestion_distance(token: &str, suggestions: &[String]) -> Option<usize> {
297 let lowered = token.to_lowercase();
298 suggestions
299 .iter()
300 .filter(|s| !s.chars().any(char::is_whitespace))
301 .map(|s| strsim::damerau_levenshtein(&lowered, &s.to_lowercase()))
302 .min()
303}
304
305fn occurrences(text: &str, token: &str) -> usize {
310 if token.is_empty() {
311 return 0;
312 }
313 let mut count = 0;
314 let mut cursor = 0;
315 while let Some(found) = text[cursor..].find(token) {
316 let start = cursor + found;
317 let end = start + token.len();
318 let before_ok = start == 0
319 || !text[..start]
320 .chars()
321 .next_back()
322 .is_some_and(char::is_alphanumeric);
323 let after_ok = end >= text.len()
324 || !text[end..]
325 .chars()
326 .next()
327 .is_some_and(char::is_alphanumeric);
328 if before_ok && after_ok {
329 count += 1;
330 if count > 1 {
331 return count;
332 }
333 }
334 cursor = end.max(start + 1);
335 if cursor >= text.len() {
336 break;
337 }
338 }
339 count
340}
341
342fn is_capitalised(token: &str) -> bool {
343 token.chars().next().is_some_and(char::is_uppercase)
344}
345
346fn starts_sentence(text: &str, start: usize) -> bool {
351 let preceding = text[..start.min(text.len())].trim_end();
352 preceding.chars().next_back().is_none_or(|c| {
353 matches!(
354 c,
355 '.' | '!' | '?' | ':' | ';' | '\n' | '"' | '\'' | '(' | '['
356 )
357 })
358}
359
360const WINDOW: usize = 48;
361
362fn preceding_window(text: &str, start: usize) -> &str {
363 let start = start.min(text.len());
364 let lo = text.floor_char_boundary(start.saturating_sub(WINDOW));
365 &text[lo..start]
366}
367
368fn following_window(text: &str, end: usize) -> &str {
369 let end = end.min(text.len());
370 let hi = text.ceil_char_boundary((end + WINDOW).min(text.len()));
371 &text[end..hi]
372}
373
374#[cfg(test)]
375mod tests;