Skip to main content

lang_check/
names.rs

1//! Opt-in detection of human names in spelling diagnostics.
2//!
3//! Spell checkers flag names constantly, and the only escape hatch is adding each one to
4//! a dictionary — which never converges. This module decides whether a token that an
5//! engine flagged as a misspelling is in fact a person's name, so the diagnostic can be
6//! dropped.
7//!
8//! # Engine independence
9//!
10//! Nothing here knows which engine produced the diagnostic. The only inputs are the
11//! flagged token, the surrounding document text, and the engine's own `suggestions`
12//! list — which both Harper and `LanguageTool` populate. See [`crate::suppression`].
13//!
14//! # Why no single signal is trusted
15//!
16//! Measurement against a live `LanguageTool` server showed real surnames sitting at edit
17//! distance 1 from real words (`Hoare`→`Hare`, `Ackermann`→`Ackerman`), so "no plausible
18//! correction exists" cannot carry the decision alone. In the other direction, the
19//! crowdsourced name lists that feed the gazetteer contain hundreds of entries that are
20//! actually common misspellings (`thier`, `balck`, `alway`); those are pruned at build
21//! time, but the lesson stands — a gazetteer hit alone must not silence a diagnostic
22//! either.
23//!
24//! So the verdict requires **at least two independent signals** and a score above a
25//! configurable threshold. Missing a name costs a stray squiggle; suppressing a real
26//! typo costs trust in every squiggle, so the bias is deliberately toward staying noisy.
27
28use fst::Set;
29use regex::Regex;
30use std::sync::LazyLock;
31
32/// The compiled gazetteer, embedded at build time.
33///
34/// Generated by `scripts/build-name-gazetteer.py` + `examples/build-name-fst.rs`.
35static 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/// How much corroboration is required before a diagnostic is dropped.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Aggressiveness {
44    /// Only suppress when the evidence is overwhelming.
45    Conservative,
46    /// Default: a gazetteer hit plus one corroborating signal, or equivalent.
47    #[default]
48    Balanced,
49    /// Suppress on weaker evidence. Expect occasional real typos to be missed.
50    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/// An individual piece of evidence that a token is a name.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum NameSignal {
66    /// The token (or a morphological base of it) is a known given name or surname.
67    Gazetteer,
68    /// No engine suggestion is close enough for the token to be a plausible typo.
69    Orphan,
70    /// The engine offered no correction at all.
71    NoSuggestions,
72    /// The same spelling occurs more than once in the document.
73    Repetition,
74    /// A title, salutation, possessive or citation pattern surrounds the token.
75    Context,
76    /// Capitalised somewhere other than the start of a sentence.
77    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    /// Short stable tag, surfaced in the inspector.
90    #[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/// The outcome of examining one flagged token.
104#[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    /// Comma-separated signal tags, for the inspector payload.
113    #[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
123/// Everything the detector needs about one flagged token.
124pub struct NameQuery<'a> {
125    /// The flagged token, exactly as it appears in the document.
126    pub token: &'a str,
127    /// The full document text.
128    pub text: &'a str,
129    /// Byte offset of the token within `text`.
130    pub start_byte: usize,
131    /// Byte offset of the token end within `text`.
132    pub end_byte: usize,
133    /// The engine's proposed corrections.
134    pub suggestions: &'a [String],
135}
136
137/// Titles that precede a name. Deliberately multilingual: the extension checks German,
138/// French and Spanish documents too.
139static 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
154/// Salutations and bylines: "Dear X", "Hi X", "by X".
155static 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
162/// Possessive or academic-citation shapes immediately following the token.
163static 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
168/// A capitalised word immediately after the token — "Jon Sterling".
169static CAPITALISED_AFTER: LazyLock<Regex> =
170    LazyLock::new(|| Regex::new(r"^\s+\p{Lu}\p{Ll}+").expect("valid capitalised pattern"));
171
172/// Detects human names among flagged tokens.
173pub struct NameFilter {
174    aggressiveness: Aggressiveness,
175    /// Language tag the document is being checked as, e.g. `en-US`, `de-DE`.
176    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    /// Whether capitalisation carries information in this language.
189    ///
190    /// German capitalises every noun, so "Bäcker" tells you nothing about whether the
191    /// token is a surname or the word for baker. Giving shape a vote there would make
192    /// the filter noticeably more eager on exactly the language where it should not be.
193    fn capitalisation_is_informative(&self) -> bool {
194        !self.language.starts_with("de")
195    }
196
197    /// Examine one flagged token.
198    #[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        // Two independent signals minimum. A gazetteer hit alone is not enough: the
229        // name lists are crowdsourced and a lone hit on a lowercase token in running
230        // text is far more likely to be a typo than a person.
231        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
253/// Case-insensitive gazetteer lookup, trying morphological bases in turn.
254fn 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
264/// The token plus the inflected forms a name commonly takes.
265///
266/// English possessives (`Merkel's`) and German genitives (`Merkels`, `Bachs`) would
267/// otherwise miss a gazetteer that stores base forms only.
268fn 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    // Genitive/plural -s, and the German weak-declension -n / -en.
279    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
290/// Smallest edit distance between the token and any single-word suggestion.
291///
292/// Returns `None` when the engine offered nothing usable. Suggestions containing
293/// whitespace are ignored: `LanguageTool` answers `Abramsky` with `Abram sky`, a word-split
294/// proposal that is one edit away by character count but is not evidence that the token
295/// is a misspelling of a known word.
296fn 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
305/// How many times `token` appears in `text` as a whole word.
306///
307/// Names recur; typos are usually one-off. Counting stops at 2 because that is all the
308/// signal needs, which keeps this cheap on large documents.
309fn 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
346/// Whether the token at `start` opens a sentence.
347///
348/// Sentence-initial capitalisation is not evidence of a name, so the shape signal has to
349/// exclude it.
350fn 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;