Skip to main content

pos_tagger/
tagger.rs

1//! Part-of-speech tagger.
2//!
3//! Tagging runs in two phases. First each word gets an initial tag from the
4//! lexicon, falling back to a lowercase lookup and then to `NN`. Second the eight
5//! contextual rules rewrite tags left to right.
6
7use crate::lexicon;
8use crate::rules;
9use std::collections::HashMap;
10
11/// A Brill-style English part-of-speech tagger.
12///
13/// Each tagger owns its lexicon, so [`Tagger::extend_lexicon`] affects only that
14/// instance. Construct one with [`Tagger::new`].
15///
16/// ```
17/// use pos_tagger::Tagger;
18/// let tagger = Tagger::new();
19/// let tagged = tagger.tag(&["the", "dog"]);
20/// assert_eq!(tagged, vec![
21///     ("the".to_string(), "DT".to_string()),
22///     ("dog".to_string(), "NN".to_string()),
23/// ]);
24/// ```
25#[derive(Debug, Clone)]
26pub struct Tagger {
27    lexicon: HashMap<String, Vec<String>>,
28}
29
30impl Default for Tagger {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl Tagger {
37    /// Create a tagger with the built-in English lexicon.
38    pub fn new() -> Self {
39        Tagger {
40            lexicon: lexicon::load(),
41        }
42    }
43
44    /// Tag each word in `words`.
45    ///
46    /// Returns a vector the same length as the input. Each entry pairs the
47    /// original word with its tag. The input is never modified.
48    ///
49    /// `words` is any slice whose elements borrow as `str`, so `&[&str]`,
50    /// `&[String]`, and the lexer's `Vec<String>` output all pass without an
51    /// intermediate `Vec<&str>`. Write `tagger.tag(&lexer.lex(text))` directly.
52    ///
53    /// Each result pair owns its word as a fresh `String`. The pair shape is the
54    /// public contract, so the word is copied out of the borrowed input. Callers
55    /// that only need tags can read the second field and drop the first.
56    ///
57    /// An empty token tags as `NN`. It is a lexicon key with an empty tag list,
58    /// and the empty list falls back to `NN` instead of panicking.
59    pub fn tag<S: AsRef<str>>(&self, words: &[S]) -> Vec<(String, String)> {
60        let mut sentence: Vec<(String, String)> = words
61            .iter()
62            .map(|word| {
63                let word = word.as_ref();
64                (word.to_string(), self.initial_tag(word))
65            })
66            .collect();
67
68        for index in 0..sentence.len() {
69            rules::apply_all(&mut sentence, index);
70        }
71        sentence
72    }
73
74    /// Compute the initial tag for one word before the rules run.
75    ///
76    /// Look up the exact word first. On a miss, look up the lowercase form. On a
77    /// second miss, default to `NN`. When a lookup hits, use the first tag in the
78    /// candidate list.
79    fn initial_tag(&self, word: &str) -> String {
80        if let Some(tags) = self.lexicon.get(word) {
81            return first_tag(tags);
82        }
83        if let Some(tags) = self.lexicon.get(&word.to_lowercase()) {
84            return first_tag(tags);
85        }
86        "NN".to_string()
87    }
88
89    /// Report whether `word` is in the lexicon.
90    ///
91    /// Returns true when the exact word is a key or when its lowercase form is a
92    /// key. The empty string is a key with an empty tag list, so
93    /// `word_in_lexicon("")` returns true.
94    ///
95    /// The lexicon is a `HashMap`, so object member names such as `constructor`,
96    /// `valueOf`, `hasOwnProperty`, `toString`, and `__proto__` return false
97    /// unless they are real keys. None of them are. A lookup over a plain object
98    /// would inherit these names and report true. This crate keeps the `HashMap`
99    /// result on purpose.
100    pub fn word_in_lexicon(&self, word: &str) -> bool {
101        if self.lexicon.contains_key(word) {
102            return true;
103        }
104        self.lexicon.contains_key(&word.to_lowercase())
105    }
106
107    /// Add entries from `entries`, keeping existing keys untouched.
108    ///
109    /// Only keys absent from the current lexicon are inserted. Existing entries
110    /// are never overwritten.
111    ///
112    /// `entries` is any iterable of `(word, tags)` pairs. A `HashMap`, a
113    /// `BTreeMap`, a `Vec`, an array, or a `map` adaptor all work, so callers do
114    /// not build a `HashMap` first.
115    pub fn extend_lexicon<I>(&mut self, entries: I)
116    where
117        I: IntoIterator<Item = (String, Vec<String>)>,
118    {
119        for (word, tags) in entries {
120            self.lexicon.entry(word).or_insert(tags);
121        }
122    }
123}
124
125/// First tag of a candidate list, or `NN` when the list is empty.
126///
127/// The lexicon has one empty-tag entry, the key for the empty string. The lexer
128/// never emits an empty token, so this path is unreachable through the normal
129/// pipeline. Returning `NN` keeps a direct `tag(&[""])` call total instead of
130/// panicking.
131fn first_tag(tags: &[String]) -> String {
132    tags.first().cloned().unwrap_or_else(|| "NN".to_string())
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn unknown_word_defaults_to_nn() {
141        let t = Tagger::new();
142        assert_eq!(t.tag(&["Zzqxxw"]), vec![("Zzqxxw".into(), "NN".into())]);
143    }
144
145    #[test]
146    fn lowercase_fallback() {
147        let t = Tagger::new();
148        assert_eq!(t.tag(&["THE"]), vec![("THE".into(), "DT".into())]);
149    }
150}