Skip to main content

gaze_recognizers/
anchored_match.rs

1use gaze_types::{Candidate, ConflictTier, DetectContext, LocaleTag, PiiClass, Recognizer};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum AnchoredBoundary {
5    Punctuation,
6    Whitespace,
7    LineEnd,
8}
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum NameShape {
12    PersonName,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum CuePosition {
17    Before,
18    After,
19}
20
21/// Recognizes person names near literal locale cues.
22///
23/// The `person_name` grammar accepts Unicode letter components with internal
24/// apostrophe or hyphen joins, initials written as a single uppercase letter
25/// followed by `.`, and non-leading particles (`de`, `la`, `van`, `der`) only
26/// when sandwiched between uppercase components. Matching is max-greedy,
27/// capped at four components and 64 bytes.
28pub struct AnchoredMatchRecognizer {
29    id: String,
30    cues: Vec<String>,
31    boundary: AnchoredBoundary,
32    right_window_chars: u16,
33    name_shape: NameShape,
34    cue_position: CuePosition,
35    source: String,
36    score: f32,
37    priority: i32,
38    token_family: String,
39    locales: Vec<LocaleTag>,
40    min_components: usize,
41}
42
43impl AnchoredMatchRecognizer {
44    #[allow(clippy::too_many_arguments)]
45    pub fn new(
46        id: String,
47        cues: Vec<String>,
48        boundary: AnchoredBoundary,
49        right_window_chars: u16,
50        name_shape: NameShape,
51        cue_position: CuePosition,
52        source_short_label: String,
53        min_components: usize,
54        score: f32,
55        priority: i32,
56    ) -> Self {
57        let mut cues = cues
58            .into_iter()
59            .filter(|cue| !cue.trim().is_empty())
60            .collect::<Vec<_>>();
61        cues.sort_by_key(|cue| std::cmp::Reverse(cue.chars().count()));
62        Self {
63            id,
64            cues,
65            boundary,
66            right_window_chars,
67            name_shape,
68            cue_position,
69            source: format!("structural.{source_short_label}"),
70            score,
71            priority,
72            token_family: "name.counter".to_string(),
73            locales: vec![LocaleTag::Global],
74            min_components,
75        }
76    }
77
78    fn candidate_after_cue(&self, input: &str, cue_end: usize) -> Option<std::ops::Range<usize>> {
79        let window = bounded_window_after(input, cue_end, self.right_window_chars);
80        let window = if matches!(self.boundary, AnchoredBoundary::LineEnd) {
81            window
82                .split_once(['\n', '\r'])
83                .map_or(window, |(line, _)| line)
84        } else {
85            window
86        };
87        let relative = is_person_name_candidate_with_min(window, self.min_components)?;
88        Some((cue_end + relative.start)..(cue_end + relative.end))
89    }
90
91    fn candidate_before_cue(
92        &self,
93        input: &str,
94        cue_start: usize,
95    ) -> Option<std::ops::Range<usize>> {
96        let window = bounded_window_before(input, cue_start, self.right_window_chars);
97        let relative = last_person_name_candidate_with_min(window, self.min_components)?;
98        let window_start = cue_start - window.len();
99        Some((window_start + relative.start)..(window_start + relative.end))
100    }
101}
102
103impl Recognizer for AnchoredMatchRecognizer {
104    fn id(&self) -> &str {
105        &self.id
106    }
107
108    fn supported_class(&self) -> &PiiClass {
109        &PiiClass::Name
110    }
111
112    fn detect(&self, input: &str, _ctx: &DetectContext<'_>) -> Vec<Candidate> {
113        if !matches!(self.name_shape, NameShape::PersonName) {
114            return Vec::new();
115        }
116
117        let mut candidates = Vec::new();
118        for cue in &self.cues {
119            for cue_range in find_cue_ranges(input, cue) {
120                let span = match self.cue_position {
121                    CuePosition::Before => self.candidate_after_cue(input, cue_range.end),
122                    CuePosition::After => self.candidate_before_cue(input, cue_range.start),
123                };
124                let Some(span) = span else {
125                    continue;
126                };
127                if !self.boundary_allows(input, &span) {
128                    continue;
129                }
130                candidates.push(Candidate::new(
131                    span,
132                    PiiClass::Name,
133                    self.id.clone(),
134                    self.score,
135                    self.priority,
136                    None,
137                    self.token_family.clone(),
138                    self.source.clone(),
139                    ConflictTier::None,
140                    Vec::new(),
141                ));
142            }
143        }
144        candidates.sort_by_key(|candidate| candidate.span.start);
145        candidates.dedup_by(|a, b| a.span == b.span);
146        candidates
147    }
148
149    fn token_family(&self) -> &str {
150        &self.token_family
151    }
152
153    fn locales(&self) -> &[LocaleTag] {
154        &self.locales
155    }
156}
157
158impl AnchoredMatchRecognizer {
159    fn boundary_allows(&self, input: &str, span: &std::ops::Range<usize>) -> bool {
160        match self.boundary {
161            AnchoredBoundary::Punctuation => input[span.end..]
162                .chars()
163                .find(|ch| !ch.is_whitespace())
164                .is_none_or(|ch| matches!(ch, '.' | ',' | ':' | ';' | ')' | ']' | '>' | '\n')),
165            AnchoredBoundary::Whitespace => input[span.end..]
166                .chars()
167                .next()
168                .is_none_or(|ch| ch.is_whitespace() || matches!(ch, '.' | ',' | ':' | ';')),
169            AnchoredBoundary::LineEnd => input[span.end..]
170                .chars()
171                .next()
172                .is_none_or(|ch| ch == '\n' || ch == '\r'),
173        }
174    }
175}
176
177pub fn is_person_name_candidate(input: &str) -> Option<std::ops::Range<usize>> {
178    is_person_name_candidate_with_min(input, 1)
179}
180
181fn is_person_name_candidate_with_min(
182    input: &str,
183    min_components: usize,
184) -> Option<std::ops::Range<usize>> {
185    for (start, _) in input.char_indices().filter(|(_, ch)| ch.is_uppercase()) {
186        if let Some(span) = person_name_at(input, start, min_components) {
187            return Some(span);
188        }
189    }
190    None
191}
192
193fn last_person_name_candidate_with_min(
194    input: &str,
195    min_components: usize,
196) -> Option<std::ops::Range<usize>> {
197    input
198        .char_indices()
199        .filter(|(_, ch)| ch.is_uppercase())
200        .filter_map(|(start, _)| person_name_at(input, start, min_components))
201        .next_back()
202}
203
204fn person_name_at(
205    input: &str,
206    start: usize,
207    min_components: usize,
208) -> Option<std::ops::Range<usize>> {
209    let mut end = start;
210    let mut components = 0usize;
211    let mut uppercase_components = 0usize;
212    let mut pending_particles: Vec<(usize, usize)> = Vec::new();
213    let mut cursor = start;
214
215    while components < 4 {
216        let Some((token_start, token_end)) = next_space_delimited(input, cursor) else {
217            break;
218        };
219        if token_start != cursor && !input[cursor..token_start].chars().all(char::is_whitespace) {
220            break;
221        }
222        let token = trim_trailing_boundary(&input[token_start..token_end]);
223        let effective_end = token_start + token.len();
224        if token.is_empty() {
225            break;
226        }
227
228        if is_upper_component(token) {
229            components += 1;
230            uppercase_components += 1;
231            pending_particles.clear();
232            end = effective_end;
233            cursor = token_end;
234            continue;
235        }
236
237        if components > 0 && is_particle(token) {
238            pending_particles.push((token_start, effective_end));
239            components += 1;
240            cursor = token_end;
241            continue;
242        }
243        break;
244    }
245
246    if !pending_particles.is_empty() {
247        end = pending_particles[0].0.saturating_sub(1);
248        components = components.saturating_sub(pending_particles.len());
249    }
250
251    let candidate = &input[start..end];
252    if has_organization_suffix(candidate) {
253        return None;
254    }
255    (components >= min_components
256        && uppercase_components > 0
257        && candidate.len() <= 64
258        && !candidate.is_empty())
259    .then_some(start..end)
260}
261
262fn next_space_delimited(input: &str, cursor: usize) -> Option<(usize, usize)> {
263    let start = input[cursor..]
264        .char_indices()
265        .find(|(_, ch)| !ch.is_whitespace())
266        .map(|(offset, _)| cursor + offset)?;
267    let end = input[start..]
268        .char_indices()
269        .find(|(_, ch)| ch.is_whitespace())
270        .map_or(input.len(), |(offset, _)| start + offset);
271    Some((start, end))
272}
273
274fn trim_trailing_boundary(token: &str) -> &str {
275    if token.len() == 2 {
276        let mut chars = token.chars();
277        if chars.next().is_some_and(|ch| ch.is_uppercase()) && chars.next() == Some('.') {
278            return token;
279        }
280    }
281    token.trim_end_matches(['.', ',', ':', ';', ')', ']', '>'])
282}
283
284fn is_upper_component(token: &str) -> bool {
285    if token.len() == 2 {
286        let mut chars = token.chars();
287        if chars.next().is_some_and(|ch| ch.is_uppercase()) && chars.next() == Some('.') {
288            return true;
289        }
290    }
291    let mut chars = token.chars();
292    let Some(first) = chars.next() else {
293        return false;
294    };
295    first.is_uppercase()
296        && !token.chars().all(|ch| ch.is_uppercase())
297        && token.chars().all(|ch| {
298            ch.is_alphabetic() || matches!(ch, '\'' | '-') || (ch == '.' && token.len() == 2)
299        })
300        && token
301            .split(['\'', '-'])
302            .all(|part| part.chars().next().is_some_and(|ch| ch.is_alphabetic()))
303}
304
305fn is_particle(token: &str) -> bool {
306    matches!(token, "de" | "la" | "van" | "der")
307}
308
309fn has_organization_suffix(candidate: &str) -> bool {
310    candidate
311        .split_whitespace()
312        .next_back()
313        .is_some_and(|last| {
314            matches!(
315                trim_trailing_boundary(last),
316                "Corp" | "Corporation" | "Inc" | "LLC" | "GmbH" | "AG"
317            )
318        })
319}
320
321fn find_cue_ranges(input: &str, cue: &str) -> Vec<std::ops::Range<usize>> {
322    let input_lower = input.to_lowercase();
323    let cue_lower = cue.to_lowercase();
324    input_lower
325        .match_indices(&cue_lower)
326        .filter_map(|(start, matched)| {
327            let end = start + matched.len();
328            (is_boundary(input, start, true) && is_boundary(input, end, false))
329                .then_some(start..end)
330        })
331        .collect()
332}
333
334fn is_boundary(input: &str, index: usize, before: bool) -> bool {
335    let ch = if before {
336        input[..index].chars().next_back()
337    } else {
338        input[index..].chars().next()
339    };
340    ch.is_none_or(|ch| !ch.is_alphanumeric() && ch != '_' && ch != '-')
341}
342
343fn bounded_window_after(input: &str, start: usize, max_chars: u16) -> &str {
344    let end = input[start..]
345        .char_indices()
346        .nth(max_chars as usize)
347        .map_or(input.len(), |(offset, _)| start + offset);
348    &input[start..end]
349}
350
351fn bounded_window_before(input: &str, end: usize, max_chars: u16) -> &str {
352    let start = input[..end]
353        .char_indices()
354        .rev()
355        .nth(max_chars as usize)
356        .map_or(0, |(offset, _)| offset);
357    &input[start..end]
358}
359
360#[cfg(test)]
361mod tests {
362    use std::collections::HashMap;
363
364    use gaze_types::{DictionaryBundle, LocaleTag};
365
366    use super::*;
367
368    #[test]
369    fn person_name_shape_accepts_required_matches() {
370        for value in [
371            "Łukasz Müller",
372            "O'Brien",
373            "Alice de la Cruz",
374            "Jean-Paul Sartre",
375            "Müller-Schmidt",
376            "José María García",
377        ] {
378            assert!(
379                is_person_name_candidate(value).is_some(),
380                "{value} should match"
381            );
382        }
383    }
384
385    #[test]
386    fn person_name_shape_rejects_required_non_matches() {
387        for value in ["IBM", "mailgun", "the alice"] {
388            assert!(
389                is_person_name_candidate(value).is_none(),
390                "{value} should not match"
391            );
392        }
393        assert!(is_person_name_candidate_with_min("Acme Corp", 2).is_none());
394    }
395
396    #[test]
397    fn detects_cue_followed_by_name_with_punctuation_boundary() {
398        let recognizer = test_recognizer(
399            "test.from_cue",
400            vec!["from"],
401            AnchoredBoundary::Punctuation,
402            CuePosition::Before,
403            "from",
404        );
405        let hits = recognizer.detect("Hello from Alice Example, please reply.", &ctx());
406
407        assert_eq!(hits.len(), 1);
408        assert_eq!(hits[0].span, 11..24);
409        assert_eq!(hits[0].recognizer_id, "test.from_cue");
410        assert_eq!(hits[0].source, "structural.from");
411    }
412
413    #[test]
414    fn detects_line_end_and_whitespace_bounded_names() {
415        let line_end = test_recognizer(
416            "test.line",
417            vec!["from"],
418            AnchoredBoundary::LineEnd,
419            CuePosition::Before,
420            "from",
421        );
422        assert_eq!(
423            line_end.detect("Forwarded from Alice Example\nBody", &ctx())[0].span,
424            15..28
425        );
426
427        let whitespace = test_recognizer(
428            "test.ws",
429            vec!["reply to"],
430            AnchoredBoundary::Whitespace,
431            CuePosition::Before,
432            "agent_recipient",
433        );
434        assert_eq!(
435            whitespace.detect("Please reply to Alice Example today", &ctx())[0].span,
436            16..29
437        );
438    }
439
440    #[test]
441    fn detects_cue_after_name_and_rejects_window_overflow() {
442        let recognizer = test_recognizer(
443            "test.after",
444            vec!["sent"],
445            AnchoredBoundary::Whitespace,
446            CuePosition::After,
447            "footer",
448        );
449        assert_eq!(
450            recognizer.detect("Alice Example sent this", &ctx())[0].span,
451            0..13
452        );
453
454        let short = AnchoredMatchRecognizer::new(
455            "test.short".to_string(),
456            vec!["from".to_string()],
457            AnchoredBoundary::Punctuation,
458            4,
459            NameShape::PersonName,
460            CuePosition::Before,
461            "from".to_string(),
462            2,
463            0.9,
464            10,
465        );
466        assert!(short.detect("from Alice Example.", &ctx()).is_empty());
467    }
468
469    #[test]
470    fn min_components_is_explicit_not_source_label_derived() {
471        let one_component_non_forward = AnchoredMatchRecognizer::new(
472            "test.one_component".to_string(),
473            vec!["from".to_string()],
474            AnchoredBoundary::Punctuation,
475            64,
476            NameShape::PersonName,
477            CuePosition::Before,
478            "agent_recipient".to_string(),
479            1,
480            0.9,
481            10,
482        );
483        assert_eq!(
484            one_component_non_forward.detect("from Alice:", &ctx())[0].span,
485            5..10
486        );
487
488        let two_component_forward = AnchoredMatchRecognizer::new(
489            "test.two_component".to_string(),
490            vec!["from".to_string()],
491            AnchoredBoundary::Punctuation,
492            64,
493            NameShape::PersonName,
494            CuePosition::Before,
495            "forward_marker".to_string(),
496            2,
497            0.9,
498            10,
499        );
500        assert!(two_component_forward
501            .detect("from Alice:", &ctx())
502            .is_empty());
503    }
504
505    #[test]
506    fn handles_unicode_overlap_and_non_name_rejection() {
507        let recognizer = test_recognizer(
508            "test.forward",
509            vec!["Forwarded", "Forwarded message from"],
510            AnchoredBoundary::Punctuation,
511            CuePosition::Before,
512            "forward_marker",
513        );
514        let hits = recognizer.detect("Forwarded message from Łukasz Müller:", &ctx());
515        assert_eq!(hits.len(), 1);
516        assert_eq!(
517            &"Forwarded message from Łukasz Müller:"[hits[0].span.clone()],
518            "Łukasz Müller"
519        );
520
521        assert!(recognizer
522            .detect("Forwarded message from mailgun:", &ctx())
523            .is_empty());
524    }
525
526    #[test]
527    fn emits_structural_source_labels_for_core_anchored_match_families() {
528        for (short_label, cue, boundary, input, expected_source) in [
529            (
530                "forward_marker",
531                "Forwarded message from",
532                AnchoredBoundary::Punctuation,
533                "Forwarded message from Alice Example:",
534                "structural.forward_marker",
535            ),
536            (
537                "agent_recipient",
538                "antwortest",
539                AnchoredBoundary::Punctuation,
540                "Du antwortest als Artistfy-Support an Alice Example.",
541                "structural.agent_recipient",
542            ),
543            (
544                "footer",
545                "Sent by",
546                AnchoredBoundary::Whitespace,
547                "Sent by Alice Example via Mailgun",
548                "structural.footer",
549            ),
550        ] {
551            let recognizer = test_recognizer(
552                &format!("test.{short_label}"),
553                vec![cue],
554                boundary,
555                CuePosition::Before,
556                short_label,
557            );
558            let hits = recognizer.detect(input, &ctx());
559
560            assert_eq!(
561                hits.first().map(|hit| hit.source.as_str()),
562                Some(expected_source),
563                "{short_label} should persist the structural source label"
564            );
565        }
566    }
567
568    fn test_recognizer(
569        id: &str,
570        cues: Vec<&str>,
571        boundary: AnchoredBoundary,
572        cue_position: CuePosition,
573        source_short_label: &str,
574    ) -> AnchoredMatchRecognizer {
575        AnchoredMatchRecognizer::new(
576            id.to_string(),
577            cues.into_iter().map(str::to_string).collect(),
578            boundary,
579            64,
580            NameShape::PersonName,
581            cue_position,
582            source_short_label.to_string(),
583            2,
584            0.91,
585            60,
586        )
587    }
588
589    fn ctx() -> DetectContext<'static> {
590        let dictionaries = Box::leak(Box::new(DictionaryBundle::from_entries(HashMap::new())));
591        let locale_chain = Box::leak(Box::new([LocaleTag::Global]));
592        DetectContext::new(locale_chain, dictionaries)
593    }
594}