Skip to main content

cloakrs_patterns/
person_name.rs

1use crate::common::{compile_regex, confidence, context_boost};
2use cloakrs_core::{Confidence, EntityType, Locale, PiiEntity, Recognizer, Span};
3use once_cell::sync::Lazy;
4use regex::Regex;
5
6static PERSON_NAME_REGEX: Lazy<Regex> = Lazy::new(|| {
7    compile_regex(
8        r"\b(?:(?:Mr|Mrs|Ms|Dr)\.?\s+)?([A-Z][a-z]{1,24})(?:\s+[A-Z]\.)?\s+([A-Z][a-z]{1,24}(?:[-'][A-Z][a-z]{1,24})?)\b",
9    )
10});
11
12const CONTEXT_WORDS: &[&str] = &[
13    "name",
14    "customer",
15    "client",
16    "patient",
17    "employee",
18    "user",
19    "contact",
20    "person",
21    "account holder",
22    "full name",
23    "signed by",
24    "author",
25    "recipient",
26    "sender",
27    "attn",
28    "dear",
29    "mr",
30    "mrs",
31    "ms",
32    "dr",
33];
34
35const FIRST_NAMES: &[&str] = &[
36    "aaron",
37    "adam",
38    "alex",
39    "alice",
40    "anna",
41    "anthony",
42    "barbara",
43    "benjamin",
44    "brian",
45    "carla",
46    "charles",
47    "chris",
48    "christopher",
49    "daniel",
50    "david",
51    "elizabeth",
52    "emily",
53    "emma",
54    "eric",
55    "fatima",
56    "george",
57    "hannah",
58    "isabella",
59    "james",
60    "jane",
61    "jan",
62    "jennifer",
63    "john",
64    "jose",
65    "joseph",
66    "julia",
67    "kadir",
68    "laura",
69    "linda",
70    "lisa",
71    "maria",
72    "mark",
73    "mary",
74    "michael",
75    "mohammed",
76    "nancy",
77    "olivia",
78    "patricia",
79    "paul",
80    "peter",
81    "robert",
82    "sarah",
83    "susan",
84    "thomas",
85    "william",
86];
87
88const LAST_NAMES: &[&str] = &[
89    "adams",
90    "anderson",
91    "brown",
92    "clark",
93    "davis",
94    "evans",
95    "garcia",
96    "green",
97    "hall",
98    "harris",
99    "jackson",
100    "johnson",
101    "jones",
102    "khan",
103    "lee",
104    "lewis",
105    "martin",
106    "martinez",
107    "miller",
108    "moore",
109    "nguyen",
110    "patel",
111    "perez",
112    "roberts",
113    "rodriguez",
114    "sanchez",
115    "smith",
116    "taylor",
117    "thomas",
118    "thompson",
119    "walker",
120    "white",
121    "williams",
122    "wilson",
123    "wright",
124    "young",
125];
126
127const NON_NAME_PHRASES: &[&str] = &[
128    "api key",
129    "aws access",
130    "credit card",
131    "date of",
132    "not found",
133    "user path",
134    "physical address",
135    "social security",
136    "json web",
137    "united states",
138    "new york",
139    "san francisco",
140    "los angeles",
141];
142
143/// Recognizes common person names using a conservative dictionary-backed heuristic.
144///
145/// # Examples
146///
147/// ```
148/// use cloakrs_core::{EntityType, Recognizer};
149/// use cloakrs_patterns::PersonNameRecognizer;
150///
151/// let findings = PersonNameRecognizer.scan("customer name John Smith");
152/// assert_eq!(findings[0].entity_type, EntityType::PersonName);
153/// ```
154#[derive(Debug, Clone, Copy, Default)]
155pub struct PersonNameRecognizer;
156
157impl Recognizer for PersonNameRecognizer {
158    fn id(&self) -> &str {
159        "person_name_dictionary_v1"
160    }
161
162    fn entity_type(&self) -> EntityType {
163        EntityType::PersonName
164    }
165
166    fn supported_locales(&self) -> &[Locale] {
167        &[]
168    }
169
170    fn scan(&self, text: &str) -> Vec<PiiEntity> {
171        PERSON_NAME_REGEX
172            .captures_iter(text)
173            .filter_map(|captures| {
174                let matched = captures.get(0)?;
175                let first = captures.get(1)?.as_str();
176                let last = captures.get(2)?.as_str();
177                if !self.validate_parts(first, last)
178                    || !valid_boundary(text, matched.start(), matched.end())
179                {
180                    return None;
181                }
182                Some(PiiEntity {
183                    entity_type: self.entity_type(),
184                    span: Span::new(matched.start(), matched.end()),
185                    text: matched.as_str().to_string(),
186                    confidence: self.compute_confidence(text, matched.start(), first, last),
187                    recognizer_id: self.id().to_string(),
188                })
189            })
190            .collect()
191    }
192
193    fn validate(&self, candidate: &str) -> bool {
194        let Some(captures) = PERSON_NAME_REGEX.captures(candidate) else {
195            return false;
196        };
197        captures
198            .get(0)
199            .is_some_and(|matched| matched.as_str() == candidate)
200            && captures
201                .get(1)
202                .zip(captures.get(2))
203                .is_some_and(|(first, last)| self.validate_parts(first.as_str(), last.as_str()))
204    }
205}
206
207impl PersonNameRecognizer {
208    fn validate_parts(&self, first: &str, last: &str) -> bool {
209        let phrase = format!(
210            "{} {}",
211            first.to_ascii_lowercase(),
212            last.to_ascii_lowercase()
213        );
214        if NON_NAME_PHRASES.contains(&phrase.as_str()) {
215            return false;
216        }
217        FIRST_NAMES.contains(&first.to_ascii_lowercase().as_str())
218            && last
219                .split(['-', '\''])
220                .all(|part| LAST_NAMES.contains(&part.to_ascii_lowercase().as_str()))
221    }
222
223    fn compute_confidence(&self, text: &str, start: usize, first: &str, last: &str) -> Confidence {
224        let boost = context_boost(text, start, CONTEXT_WORDS);
225        let dictionary_strength = if first.len() >= 4 && last.len() >= 4 {
226            0.08
227        } else {
228            0.0
229        };
230        confidence(0.42 + dictionary_strength + boost)
231    }
232}
233
234fn valid_boundary(text: &str, start: usize, end: usize) -> bool {
235    let before = text[..start].chars().next_back();
236    let after = text[end..].chars().next();
237    !before.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
238        && !after.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn texts(input: &str) -> Vec<String> {
246        PersonNameRecognizer
247            .scan(input)
248            .into_iter()
249            .map(|finding| finding.text)
250            .collect()
251    }
252
253    #[test]
254    fn test_person_name_john_smith_detected() {
255        assert_eq!(texts("customer name John Smith"), ["John Smith"]);
256    }
257
258    #[test]
259    fn test_person_name_jane_doe_like_dictionary_rejected() {
260        assert!(texts("contact Jane Doe").is_empty());
261    }
262
263    #[test]
264    fn test_person_name_middle_initial_detected() {
265        assert_eq!(texts("patient John A. Smith"), ["John A. Smith"]);
266    }
267
268    #[test]
269    fn test_person_name_hyphenated_last_detected() {
270        assert_eq!(texts("client Maria Garcia-Smith"), ["Maria Garcia-Smith"]);
271    }
272
273    #[test]
274    fn test_person_name_in_email_rejected() {
275        assert!(texts("email john.smith@example.com").is_empty());
276    }
277
278    #[test]
279    fn test_person_name_lowercase_rejected() {
280        assert!(texts("customer john smith").is_empty());
281    }
282
283    #[test]
284    fn test_person_name_non_dictionary_rejected() {
285        assert!(texts("customer River Table").is_empty());
286    }
287
288    #[test]
289    fn test_person_name_common_phrase_rejected() {
290        assert!(texts("United States").is_empty());
291    }
292
293    #[test]
294    fn test_person_name_embedded_word_rejected() {
295        assert!(texts("xJohn Smithy").is_empty());
296    }
297
298    #[test]
299    fn test_person_name_validate_accepts_known_name() {
300        assert!(PersonNameRecognizer.validate("John Smith"));
301    }
302
303    #[test]
304    fn test_person_name_validate_rejects_unknown_name() {
305        assert!(!PersonNameRecognizer.validate("Blue Widget"));
306    }
307
308    #[test]
309    fn test_person_name_context_boosts_confidence() {
310        let with_context = PersonNameRecognizer.scan("customer name John Smith");
311        let without_context = PersonNameRecognizer.scan("value John Smith");
312        assert!(with_context[0].confidence > without_context[0].confidence);
313    }
314
315    #[test]
316    fn test_person_name_title_context_boosts_confidence() {
317        let with_context = PersonNameRecognizer.scan("Dr. John Smith");
318        let without_context = PersonNameRecognizer.scan("value John Smith");
319        assert!(with_context[0].confidence > without_context[0].confidence);
320    }
321
322    #[test]
323    fn test_person_name_supported_locales_are_universal() {
324        assert!(PersonNameRecognizer.supported_locales().is_empty());
325    }
326
327    #[test]
328    fn test_person_name_entity_type_is_person_name() {
329        assert_eq!(PersonNameRecognizer.entity_type(), EntityType::PersonName);
330    }
331}