Skip to main content

uqa_analysis/
porter.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Porter (1980) stemming algorithm.
8//!
9//! Reference: M. F. Porter, "An Algorithm for Suffix Stripping", *Program*
10//! 14(3), 1980. Note that this is the original 1980 algorithm, not Porter2
11//! (Snowball English) — they differ on edge cases such as `agreed` and
12//! `feedeing`. The output is intentionally identical to the upstream
13//! UQA stemmer contract so that BM25 doc frequencies match across
14//! engines.
15
16pub fn stem(word: &str) -> String {
17    let chars: Vec<char> = word.chars().collect();
18    if chars.len() <= 2 {
19        return word.to_owned();
20    }
21    let stemmed = stem_chars(chars);
22    stemmed.into_iter().collect()
23}
24
25fn stem_chars(mut w: Vec<char>) -> Vec<char> {
26    step_1a(&mut w);
27    step_1b(&mut w);
28    step_1c(&mut w);
29    step_2(&mut w);
30    step_3(&mut w);
31    step_4(&mut w);
32    step_5a(&mut w);
33    step_5b(&mut w);
34    w
35}
36
37fn is_consonant(w: &[char], i: usize) -> bool {
38    let c = w[i];
39    if matches!(c, 'a' | 'e' | 'i' | 'o' | 'u') {
40        return false;
41    }
42    if c == 'y' {
43        return i == 0 || !is_consonant(w, i - 1);
44    }
45    true
46}
47
48/// Porter measure m: count of VC sequences in `w[0..=j]`.
49fn measure(w: &[char], j: isize) -> usize {
50    if j < 0 {
51        return 0;
52    }
53    let j = j as usize;
54    let mut n = 0usize;
55    let mut i = 0usize;
56    loop {
57        if i > j {
58            return n;
59        }
60        if !is_consonant(w, i) {
61            break;
62        }
63        i += 1;
64    }
65    i += 1;
66    loop {
67        loop {
68            if i > j {
69                return n;
70            }
71            if is_consonant(w, i) {
72                break;
73            }
74            i += 1;
75        }
76        i += 1;
77        n += 1;
78        loop {
79            if i > j {
80                return n;
81            }
82            if !is_consonant(w, i) {
83                break;
84            }
85            i += 1;
86        }
87        i += 1;
88    }
89}
90
91fn vowel_in_stem(w: &[char], j: isize) -> bool {
92    if j < 0 {
93        return false;
94    }
95    let j = j as usize;
96    (0..=j).any(|i| !is_consonant(w, i))
97}
98
99fn double_consonant(w: &[char], j: usize) -> bool {
100    j >= 1 && w[j] == w[j - 1] && is_consonant(w, j)
101}
102
103fn cvc(w: &[char], i: usize) -> bool {
104    if i < 2 || !is_consonant(w, i) || is_consonant(w, i - 1) || !is_consonant(w, i - 2) {
105        return false;
106    }
107    !matches!(w[i], 'w' | 'x' | 'y')
108}
109
110fn ends_with(w: &[char], suffix: &[char]) -> bool {
111    if suffix.len() > w.len() {
112        return false;
113    }
114    let start = w.len() - suffix.len();
115    &w[start..] == suffix
116}
117
118fn ends_with_str(w: &[char], suffix: &str) -> bool {
119    let s: Vec<char> = suffix.chars().collect();
120    ends_with(w, &s)
121}
122
123fn truncate(w: &mut Vec<char>, n: usize) {
124    let new_len = w.len() - n;
125    w.truncate(new_len);
126}
127
128fn replace_suffix(w: &mut Vec<char>, suffix_len: usize, replacement: &str) {
129    truncate(w, suffix_len);
130    w.extend(replacement.chars());
131}
132
133fn step_1a(w: &mut Vec<char>) {
134    if ends_with_str(w, "sses") || ends_with_str(w, "ies") {
135        truncate(w, 2);
136    } else if !ends_with_str(w, "ss") && ends_with_str(w, "s") {
137        truncate(w, 1);
138    }
139}
140
141fn step_1b(w: &mut Vec<char>) {
142    if ends_with_str(w, "eed") {
143        let stem_len = w.len() - 3;
144        if measure(w, stem_len as isize - 1) > 0 {
145            truncate(w, 1);
146        }
147        return;
148    }
149    let mut matched = false;
150    for suffix in ["ed", "ing"] {
151        if ends_with_str(w, suffix)
152            && vowel_in_stem(w, w.len() as isize - suffix.len() as isize - 1)
153        {
154            truncate(w, suffix.len());
155            matched = true;
156            break;
157        }
158    }
159    if !matched {
160        return;
161    }
162    if ends_with_str(w, "at") || ends_with_str(w, "bl") || ends_with_str(w, "iz") {
163        w.push('e');
164    } else if double_consonant(w, w.len() - 1) && !matches!(w[w.len() - 1], 'l' | 's' | 'z') {
165        truncate(w, 1);
166    } else if measure(w, w.len() as isize - 1) == 1 && cvc(w, w.len() - 1) {
167        w.push('e');
168    }
169}
170
171fn step_1c(w: &mut [char]) {
172    if ends_with_str(w, "y") && vowel_in_stem(w, w.len() as isize - 2) {
173        let last = w.len() - 1;
174        w[last] = 'i';
175    }
176}
177
178fn apply_replacement_table(w: &mut Vec<char>, table: &[(&str, &str)]) {
179    for (suffix, replacement) in table {
180        if ends_with_str(w, suffix) {
181            let stem_len = w.len() - suffix.chars().count();
182            if measure(w, stem_len as isize - 1) > 0 {
183                replace_suffix(w, suffix.chars().count(), replacement);
184            }
185            return;
186        }
187    }
188}
189
190fn step_2(w: &mut Vec<char>) {
191    apply_replacement_table(
192        w,
193        &[
194            ("ational", "ate"),
195            ("tional", "tion"),
196            ("enci", "ence"),
197            ("anci", "ance"),
198            ("izer", "ize"),
199            ("abli", "able"),
200            ("alli", "al"),
201            ("entli", "ent"),
202            ("eli", "e"),
203            ("ousli", "ous"),
204            ("ization", "ize"),
205            ("ation", "ate"),
206            ("ator", "ate"),
207            ("alism", "al"),
208            ("iveness", "ive"),
209            ("fulness", "ful"),
210            ("ousness", "ous"),
211            ("aliti", "al"),
212            ("iviti", "ive"),
213            ("biliti", "ble"),
214        ],
215    );
216}
217
218fn step_3(w: &mut Vec<char>) {
219    apply_replacement_table(
220        w,
221        &[
222            ("icate", "ic"),
223            ("ative", ""),
224            ("alize", "al"),
225            ("iciti", "ic"),
226            ("ical", "ic"),
227            ("ful", ""),
228            ("ness", ""),
229        ],
230    );
231}
232
233fn step_4(w: &mut Vec<char>) {
234    const SUFFIXES: &[&str] = &[
235        "al", "ance", "ence", "er", "ic", "able", "ible", "ant", "ement", "ment", "ent", "ion",
236        "ou", "ism", "ate", "iti", "ous", "ive", "ize",
237    ];
238    for suffix in SUFFIXES {
239        if ends_with_str(w, suffix) {
240            let stem_len = w.len() - suffix.chars().count();
241            if measure(w, stem_len as isize - 1) > 1 {
242                if *suffix == "ion" {
243                    if stem_len > 0 && matches!(w[stem_len - 1], 's' | 't') {
244                        truncate(w, suffix.chars().count());
245                    }
246                } else {
247                    truncate(w, suffix.chars().count());
248                }
249            }
250            return;
251        }
252    }
253}
254
255fn step_5a(w: &mut Vec<char>) {
256    if ends_with_str(w, "e") {
257        let stem_len = w.len() - 1;
258        let m = measure(w, stem_len as isize - 1);
259        if m > 1 || (m == 1 && !cvc(w, stem_len - 1)) {
260            truncate(w, 1);
261        }
262    }
263}
264
265fn step_5b(w: &mut Vec<char>) {
266    let last = w.len().saturating_sub(1);
267    if measure(w, last as isize) > 1 && double_consonant(w, last) && w[last] == 'l' {
268        truncate(w, 1);
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn short_words_pass_through() {
278        assert_eq!(stem("a"), "a");
279        assert_eq!(stem("by"), "by");
280    }
281
282    #[test]
283    fn known_examples() {
284        assert_eq!(stem("caresses"), "caress");
285        assert_eq!(stem("ponies"), "poni");
286        assert_eq!(stem("ties"), "ti");
287        assert_eq!(stem("caress"), "caress");
288        assert_eq!(stem("cats"), "cat");
289        assert_eq!(stem("feed"), "feed");
290        assert_eq!(stem("agreed"), "agre");
291        assert_eq!(stem("conflated"), "conflat");
292        assert_eq!(stem("troubled"), "troubl");
293        assert_eq!(stem("happy"), "happi");
294        assert_eq!(stem("relational"), "relat");
295        assert_eq!(stem("conditional"), "condit");
296        assert_eq!(stem("rational"), "ration");
297        assert_eq!(stem("triplicate"), "triplic");
298        assert_eq!(stem("formative"), "form");
299        assert_eq!(stem("electrical"), "electr");
300        assert_eq!(stem("hopeful"), "hope");
301        assert_eq!(stem("goodness"), "good");
302        assert_eq!(stem("revival"), "reviv");
303        assert_eq!(stem("homologous"), "homolog");
304        assert_eq!(stem("controll"), "control");
305    }
306}