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