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
32use crate::text_util::{min_suggestion_distance, safe_slice};
33
34/// The compiled gazetteer, embedded at build time.
35///
36/// Generated by `scripts/build-name-gazetteer.py` + `examples/build-name-fst.rs`.
37static 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/// How much corroboration is required before a diagnostic is dropped.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum Aggressiveness {
46    /// Only suppress when the evidence is overwhelming.
47    Conservative,
48    /// Default: a gazetteer hit plus one corroborating signal, or equivalent.
49    #[default]
50    Balanced,
51    /// Suppress on weaker evidence. Expect occasional real typos to be missed.
52    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/// An individual piece of evidence that a token is a name.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum NameSignal {
68    /// The token (or a morphological base of it) is a known given name or surname.
69    Gazetteer,
70    /// No engine suggestion is close enough for the token to be a plausible typo.
71    Orphan,
72    /// The engine offered no correction at all.
73    NoSuggestions,
74    /// The same spelling occurs more than once in the document.
75    Repetition,
76    /// A title, salutation, possessive or citation pattern surrounds the token.
77    Context,
78    /// Capitalised somewhere other than the start of a sentence.
79    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    /// Short stable tag, surfaced in the inspector.
92    #[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/// The outcome of examining one flagged token.
106#[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    /// Comma-separated signal tags, for the inspector payload.
115    #[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
125/// Everything the detector needs about one flagged token.
126pub struct NameQuery<'a> {
127    /// The flagged token, exactly as it appears in the document.
128    pub token: &'a str,
129    /// The full document text.
130    pub text: &'a str,
131    /// Byte offset of the token within `text`.
132    pub start_byte: usize,
133    /// Byte offset of the token end within `text`.
134    pub end_byte: usize,
135    /// The engine's proposed corrections.
136    pub suggestions: &'a [String],
137}
138
139/// Titles that precede a name. Deliberately multilingual: the extension checks German,
140/// French and Spanish documents too.
141static 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
156/// Salutations and bylines: "Dear X", "Hi X", "by X".
157static 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
164/// Possessive or academic-citation shapes immediately following the token.
165static 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
170/// A capitalised word immediately after the token — "Jon Sterling".
171static CAPITALISED_AFTER: LazyLock<Regex> =
172    LazyLock::new(|| Regex::new(r"^\s+\p{Lu}\p{Ll}+").expect("valid capitalised pattern"));
173
174/// Detects human names among flagged tokens.
175pub struct NameFilter {
176    aggressiveness: Aggressiveness,
177    /// Language tag the document is being checked as, e.g. `en-US`, `de-DE`.
178    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    /// Whether capitalisation carries information in this language.
191    ///
192    /// German capitalises every noun, so "Bäcker" tells you nothing about whether the
193    /// token is a surname or the word for baker. Giving shape a vote there would make
194    /// the filter noticeably more eager on exactly the language where it should not be.
195    fn capitalisation_is_informative(&self) -> bool {
196        !self.language.starts_with("de")
197    }
198
199    /// Examine one flagged token.
200    #[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        // Two independent signals minimum. A gazetteer hit alone is not enough: the
231        // name lists are crowdsourced and a lone hit on a lowercase token in running
232        // text is far more likely to be a typo than a person.
233        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
255/// Case-insensitive gazetteer lookup, trying morphological bases in turn.
256fn 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
266/// The token plus the inflected forms a name commonly takes.
267///
268/// English possessives (`Merkel's`) and German genitives (`Merkels`, `Bachs`) would
269/// otherwise miss a gazetteer that stores base forms only.
270fn 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    // Genitive/plural -s, and the German weak-declension -n / -en.
281    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
292/// How many times `token` appears in `text` as a whole word.
293///
294/// Names recur; typos are usually one-off. Counting stops at 2 because that is all the
295/// signal needs, which keeps this cheap on large documents.
296fn 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
333/// Whether the token at `start` opens a sentence.
334///
335/// Sentence-initial capitalisation is not evidence of a name, so the shape signal has to
336/// exclude it.
337fn 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;