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(
113        &self,
114        input: &str,
115        _ctx: &DetectContext<'_>,
116    ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
117        if !matches!(self.name_shape, NameShape::PersonName) {
118            return Ok(Vec::new());
119        }
120
121        let mut candidates = Vec::new();
122        for cue in &self.cues {
123            for cue_range in find_cue_ranges(input, cue) {
124                let span = match self.cue_position {
125                    CuePosition::Before => self.candidate_after_cue(input, cue_range.end),
126                    CuePosition::After => self.candidate_before_cue(input, cue_range.start),
127                };
128                let Some(span) = span else {
129                    continue;
130                };
131                if !self.boundary_allows(input, &span) {
132                    continue;
133                }
134                candidates.push(Candidate::new(
135                    span,
136                    PiiClass::Name,
137                    self.id.clone(),
138                    self.score,
139                    self.priority,
140                    None,
141                    self.token_family.clone(),
142                    self.source.clone(),
143                    ConflictTier::None,
144                    Vec::new(),
145                ));
146            }
147        }
148        candidates.sort_by_key(|candidate| candidate.span.start);
149        candidates.dedup_by(|a, b| a.span == b.span);
150        Ok(candidates)
151    }
152
153    fn token_family(&self) -> &str {
154        &self.token_family
155    }
156
157    fn locales(&self) -> &[LocaleTag] {
158        &self.locales
159    }
160}
161
162impl AnchoredMatchRecognizer {
163    fn boundary_allows(&self, input: &str, span: &std::ops::Range<usize>) -> bool {
164        match self.boundary {
165            AnchoredBoundary::Punctuation => input[span.end..]
166                .chars()
167                .find(|ch| !ch.is_whitespace())
168                .is_none_or(|ch| matches!(ch, '.' | ',' | ':' | ';' | ')' | ']' | '>' | '\n')),
169            AnchoredBoundary::Whitespace => input[span.end..]
170                .chars()
171                .next()
172                .is_none_or(|ch| ch.is_whitespace() || matches!(ch, '.' | ',' | ':' | ';')),
173            AnchoredBoundary::LineEnd => input[span.end..]
174                .chars()
175                .next()
176                .is_none_or(|ch| ch == '\n' || ch == '\r'),
177        }
178    }
179}
180
181pub fn is_person_name_candidate(input: &str) -> Option<std::ops::Range<usize>> {
182    is_person_name_candidate_with_min(input, 1)
183}
184
185fn is_person_name_candidate_with_min(
186    input: &str,
187    min_components: usize,
188) -> Option<std::ops::Range<usize>> {
189    for (start, _) in input.char_indices().filter(|(_, ch)| ch.is_uppercase()) {
190        if let Some(span) = person_name_at(input, start, min_components) {
191            return Some(span);
192        }
193    }
194    None
195}
196
197fn last_person_name_candidate_with_min(
198    input: &str,
199    min_components: usize,
200) -> Option<std::ops::Range<usize>> {
201    input
202        .char_indices()
203        .filter(|(_, ch)| ch.is_uppercase())
204        .filter_map(|(start, _)| person_name_at(input, start, min_components))
205        .next_back()
206}
207
208fn person_name_at(
209    input: &str,
210    start: usize,
211    min_components: usize,
212) -> Option<std::ops::Range<usize>> {
213    let mut end = start;
214    let mut components = 0usize;
215    let mut uppercase_components = 0usize;
216    let mut pending_particles: Vec<(usize, usize)> = Vec::new();
217    let mut cursor = start;
218
219    while components < 4 {
220        let Some((token_start, token_end)) = next_space_delimited(input, cursor) else {
221            break;
222        };
223        if token_start != cursor && !input[cursor..token_start].chars().all(char::is_whitespace) {
224            break;
225        }
226        let token = trim_trailing_boundary(&input[token_start..token_end]);
227        let effective_end = token_start + token.len();
228        if token.is_empty() {
229            break;
230        }
231
232        if is_upper_component(token) {
233            components += 1;
234            uppercase_components += 1;
235            pending_particles.clear();
236            end = effective_end;
237            cursor = token_end;
238            continue;
239        }
240
241        if components > 0 && is_particle(token) {
242            pending_particles.push((token_start, effective_end));
243            components += 1;
244            cursor = token_end;
245            continue;
246        }
247        break;
248    }
249
250    if !pending_particles.is_empty() {
251        end = pending_particles[0].0.saturating_sub(1);
252        components = components.saturating_sub(pending_particles.len());
253    }
254
255    let candidate = &input[start..end];
256    if has_organization_suffix(candidate) {
257        return None;
258    }
259    (components >= min_components
260        && uppercase_components > 0
261        && candidate.len() <= 64
262        && !candidate.is_empty())
263    .then_some(start..end)
264}
265
266fn next_space_delimited(input: &str, cursor: usize) -> Option<(usize, usize)> {
267    let start = input[cursor..]
268        .char_indices()
269        .find(|(_, ch)| !ch.is_whitespace())
270        .map(|(offset, _)| cursor + offset)?;
271    let end = input[start..]
272        .char_indices()
273        .find(|(_, ch)| ch.is_whitespace())
274        .map_or(input.len(), |(offset, _)| start + offset);
275    Some((start, end))
276}
277
278fn trim_trailing_boundary(token: &str) -> &str {
279    if token.len() == 2 {
280        let mut chars = token.chars();
281        if chars.next().is_some_and(|ch| ch.is_uppercase()) && chars.next() == Some('.') {
282            return token;
283        }
284    }
285    token.trim_end_matches(['.', ',', ':', ';', ')', ']', '>'])
286}
287
288fn is_upper_component(token: &str) -> bool {
289    if token.len() == 2 {
290        let mut chars = token.chars();
291        if chars.next().is_some_and(|ch| ch.is_uppercase()) && chars.next() == Some('.') {
292            return true;
293        }
294    }
295    let mut chars = token.chars();
296    let Some(first) = chars.next() else {
297        return false;
298    };
299    first.is_uppercase()
300        && !token.chars().all(|ch| ch.is_uppercase())
301        && token.chars().all(|ch| {
302            ch.is_alphabetic() || matches!(ch, '\'' | '-') || (ch == '.' && token.len() == 2)
303        })
304        && token
305            .split(['\'', '-'])
306            .all(|part| part.chars().next().is_some_and(|ch| ch.is_alphabetic()))
307}
308
309fn is_particle(token: &str) -> bool {
310    matches!(token, "de" | "la" | "van" | "der")
311}
312
313fn has_organization_suffix(candidate: &str) -> bool {
314    candidate
315        .split_whitespace()
316        .next_back()
317        .is_some_and(|last| {
318            matches!(
319                trim_trailing_boundary(last),
320                "Corp" | "Corporation" | "Inc" | "LLC" | "GmbH" | "AG"
321            )
322        })
323}
324
325fn find_cue_ranges(input: &str, cue: &str) -> Vec<std::ops::Range<usize>> {
326    let input_lower = input.to_lowercase();
327    let cue_lower = cue.to_lowercase();
328    input_lower
329        .match_indices(&cue_lower)
330        .filter_map(|(start, matched)| {
331            let end = start + matched.len();
332            (is_boundary(input, start, true) && is_boundary(input, end, false))
333                .then_some(start..end)
334        })
335        .collect()
336}
337
338fn is_boundary(input: &str, index: usize, before: bool) -> bool {
339    let ch = if before {
340        input[..index].chars().next_back()
341    } else {
342        input[index..].chars().next()
343    };
344    ch.is_none_or(|ch| !ch.is_alphanumeric() && ch != '_' && ch != '-')
345}
346
347fn bounded_window_after(input: &str, start: usize, max_chars: u16) -> &str {
348    let end = input[start..]
349        .char_indices()
350        .nth(max_chars as usize)
351        .map_or(input.len(), |(offset, _)| start + offset);
352    &input[start..end]
353}
354
355fn bounded_window_before(input: &str, end: usize, max_chars: u16) -> &str {
356    let start = input[..end]
357        .char_indices()
358        .rev()
359        .nth(max_chars as usize)
360        .map_or(0, |(offset, _)| offset);
361    &input[start..end]
362}
363
364#[cfg(test)]
365mod tests {
366    use std::collections::HashMap;
367
368    use gaze_types::{DictionaryBundle, LocaleTag};
369
370    use super::*;
371
372    #[test]
373    fn person_name_shape_accepts_required_matches() {
374        for value in [
375            "Łukasz Müller",
376            "O'Brien",
377            "Alice de la Cruz",
378            "Jean-Paul Sartre",
379            "Müller-Schmidt",
380            "José María García",
381        ] {
382            assert!(
383                is_person_name_candidate(value).is_some(),
384                "{value} should match"
385            );
386        }
387    }
388
389    #[test]
390    fn person_name_shape_rejects_required_non_matches() {
391        for value in ["IBM", "mailgun", "the alice"] {
392            assert!(
393                is_person_name_candidate(value).is_none(),
394                "{value} should not match"
395            );
396        }
397        assert!(is_person_name_candidate_with_min("Acme Corp", 2).is_none());
398    }
399
400    #[test]
401    fn detects_cue_followed_by_name_with_punctuation_boundary() {
402        let recognizer = test_recognizer(
403            "test.from_cue",
404            vec!["from"],
405            AnchoredBoundary::Punctuation,
406            CuePosition::Before,
407            "from",
408        );
409        let hits = recognizer
410            .detect("Hello from Alice Example, please reply.", &ctx())
411            .unwrap();
412
413        assert_eq!(hits.len(), 1);
414        assert_eq!(hits[0].span, 11..24);
415        assert_eq!(hits[0].recognizer_id, "test.from_cue");
416        assert_eq!(hits[0].source, "structural.from");
417    }
418
419    #[test]
420    fn detects_line_end_and_whitespace_bounded_names() {
421        let line_end = test_recognizer(
422            "test.line",
423            vec!["from"],
424            AnchoredBoundary::LineEnd,
425            CuePosition::Before,
426            "from",
427        );
428        assert_eq!(
429            line_end
430                .detect("Forwarded from Alice Example\nBody", &ctx())
431                .unwrap()[0]
432                .span,
433            15..28
434        );
435
436        let whitespace = test_recognizer(
437            "test.ws",
438            vec!["reply to"],
439            AnchoredBoundary::Whitespace,
440            CuePosition::Before,
441            "agent_recipient",
442        );
443        assert_eq!(
444            whitespace
445                .detect("Please reply to Alice Example today", &ctx())
446                .unwrap()[0]
447                .span,
448            16..29
449        );
450    }
451
452    #[test]
453    fn detects_cue_after_name_and_rejects_window_overflow() {
454        let recognizer = test_recognizer(
455            "test.after",
456            vec!["sent"],
457            AnchoredBoundary::Whitespace,
458            CuePosition::After,
459            "footer",
460        );
461        assert_eq!(
462            recognizer
463                .detect("Alice Example sent this", &ctx())
464                .unwrap()[0]
465                .span,
466            0..13
467        );
468
469        let short = AnchoredMatchRecognizer::new(
470            "test.short".to_string(),
471            vec!["from".to_string()],
472            AnchoredBoundary::Punctuation,
473            4,
474            NameShape::PersonName,
475            CuePosition::Before,
476            "from".to_string(),
477            2,
478            0.9,
479            10,
480        );
481        assert!(short
482            .detect("from Alice Example.", &ctx())
483            .unwrap()
484            .is_empty());
485    }
486
487    #[test]
488    fn min_components_is_explicit_not_source_label_derived() {
489        let one_component_non_forward = AnchoredMatchRecognizer::new(
490            "test.one_component".to_string(),
491            vec!["from".to_string()],
492            AnchoredBoundary::Punctuation,
493            64,
494            NameShape::PersonName,
495            CuePosition::Before,
496            "agent_recipient".to_string(),
497            1,
498            0.9,
499            10,
500        );
501        assert_eq!(
502            one_component_non_forward
503                .detect("from Alice:", &ctx())
504                .unwrap()[0]
505                .span,
506            5..10
507        );
508
509        let two_component_forward = AnchoredMatchRecognizer::new(
510            "test.two_component".to_string(),
511            vec!["from".to_string()],
512            AnchoredBoundary::Punctuation,
513            64,
514            NameShape::PersonName,
515            CuePosition::Before,
516            "forward_marker".to_string(),
517            2,
518            0.9,
519            10,
520        );
521        assert!(two_component_forward
522            .detect("from Alice:", &ctx())
523            .unwrap()
524            .is_empty());
525    }
526
527    #[test]
528    fn handles_unicode_overlap_and_non_name_rejection() {
529        let recognizer = test_recognizer(
530            "test.forward",
531            vec!["Forwarded", "Forwarded message from"],
532            AnchoredBoundary::Punctuation,
533            CuePosition::Before,
534            "forward_marker",
535        );
536        let hits = recognizer
537            .detect("Forwarded message from Łukasz Müller:", &ctx())
538            .unwrap();
539        assert_eq!(hits.len(), 1);
540        assert_eq!(
541            &"Forwarded message from Łukasz Müller:"[hits[0].span.clone()],
542            "Łukasz Müller"
543        );
544
545        assert!(recognizer
546            .detect("Forwarded message from mailgun:", &ctx())
547            .unwrap()
548            .is_empty());
549    }
550
551    #[test]
552    fn emits_structural_source_labels_for_core_anchored_match_families() {
553        for (short_label, cue, boundary, input, expected_source) in [
554            (
555                "forward_marker",
556                "Forwarded message from",
557                AnchoredBoundary::Punctuation,
558                "Forwarded message from Alice Example:",
559                "structural.forward_marker",
560            ),
561            (
562                "agent_recipient",
563                "antwortest",
564                AnchoredBoundary::Punctuation,
565                "Du antwortest als Artistfy-Support an Alice Example.",
566                "structural.agent_recipient",
567            ),
568            (
569                "footer",
570                "Sent by",
571                AnchoredBoundary::Whitespace,
572                "Sent by Alice Example via Mailgun",
573                "structural.footer",
574            ),
575        ] {
576            let recognizer = test_recognizer(
577                &format!("test.{short_label}"),
578                vec![cue],
579                boundary,
580                CuePosition::Before,
581                short_label,
582            );
583            let hits = recognizer.detect(input, &ctx()).unwrap();
584
585            assert_eq!(
586                hits.first().map(|hit| hit.source.as_str()),
587                Some(expected_source),
588                "{short_label} should persist the structural source label"
589            );
590        }
591    }
592
593    fn test_recognizer(
594        id: &str,
595        cues: Vec<&str>,
596        boundary: AnchoredBoundary,
597        cue_position: CuePosition,
598        source_short_label: &str,
599    ) -> AnchoredMatchRecognizer {
600        AnchoredMatchRecognizer::new(
601            id.to_string(),
602            cues.into_iter().map(str::to_string).collect(),
603            boundary,
604            64,
605            NameShape::PersonName,
606            cue_position,
607            source_short_label.to_string(),
608            2,
609            0.91,
610            60,
611        )
612    }
613
614    fn ctx() -> DetectContext<'static> {
615        let dictionaries = Box::leak(Box::new(DictionaryBundle::from_entries(HashMap::new())));
616        let locale_chain = Box::leak(Box::new([LocaleTag::Global]));
617        DetectContext::new(locale_chain, dictionaries)
618    }
619}