Skip to main content

uqa_analysis/
token_filter.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Token-level filters that run after tokenization.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use unicode_normalization::UnicodeNormalization;
16
17use crate::porter;
18use crate::{AnalysisError, AnalysisResult};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type", rename_all = "snake_case")]
22pub enum TokenFilter {
23    Lowercase,
24    Stop {
25        #[serde(default = "default_stop_language")]
26        language: String,
27        #[serde(default)]
28        custom_words: Vec<String>,
29    },
30    PorterStem,
31    // The alias keeps catalogs persisted before the stable tag existed
32    // deserializable: releases up to 0.1.2 wrote the derived spelling.
33    #[serde(rename = "ascii_folding", alias = "a_s_c_i_i_folding")]
34    ASCIIFolding,
35    Synonym {
36        /// Inline `term -> [expansion, ...]` mapping. Empty when the
37        /// filter sources its mappings from `synonyms_path` instead.
38        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
39        synonyms: BTreeMap<String, Vec<String>>,
40        /// Path to a Solr / Elasticsearch-style synonym file. The file
41        /// is parsed every time the filter runs so reload-on-edit is
42        /// free; for production use cache the parsed map upstream.
43        /// Optional path to a reloadable synonym map.
44        #[serde(default, skip_serializing_if = "Option::is_none")]
45        synonyms_path: Option<PathBuf>,
46    },
47    Ngram {
48        min_gram: usize,
49        max_gram: usize,
50        #[serde(default)]
51        keep_short: bool,
52    },
53    EdgeNgram {
54        min_gram: usize,
55        max_gram: usize,
56    },
57    Length {
58        #[serde(default)]
59        min_length: usize,
60        #[serde(default)]
61        max_length: usize,
62    },
63}
64
65/// Errors raised when constructing a `Synonym` filter from a file.
66#[derive(Debug, thiserror::Error)]
67pub enum SynonymFileError {
68    #[error("synonym file not found: {0}")]
69    NotFound(PathBuf),
70    #[error("failed to read synonym file `{path}`: {source}")]
71    Io {
72        path: PathBuf,
73        #[source]
74        source: io::Error,
75    },
76}
77
78impl TokenFilter {
79    /// Validate configuration without filtering tokens. File-backed synonym
80    /// filters are read here so registration rejects missing/unreadable paths;
81    /// [`Self::filter`] reads them again on every execution to detect later
82    /// deletion, permission changes, and edits.
83    pub fn validate(&self) -> AnalysisResult<()> {
84        match self {
85            TokenFilter::Synonym {
86                synonyms_path: Some(path),
87                ..
88            } => {
89                Self::parse_synonym_file(path)?;
90                Ok(())
91            }
92            TokenFilter::Ngram {
93                min_gram, max_gram, ..
94            } => validate_gram_bounds("n-gram token filter", *min_gram, *max_gram),
95            TokenFilter::EdgeNgram { min_gram, max_gram } => {
96                validate_gram_bounds("edge n-gram token filter", *min_gram, *max_gram)
97            }
98            _ => Ok(()),
99        }
100    }
101
102    /// Build a `Synonym` filter from a Solr or Elasticsearch synonym file.
103    /// In this format,
104    /// blank lines and `#` comments are skipped, `a => b, c` defines a
105    /// one-way mapping, and `a, b, c` defines an equivalent group
106    /// where every term expands to the other group members.
107    pub fn synonym_from_path<P: AsRef<Path>>(path: P) -> Result<Self, SynonymFileError> {
108        let path = path.as_ref();
109        if !path.exists() {
110            return Err(SynonymFileError::NotFound(path.to_path_buf()));
111        }
112        // Read once at construction so unreadable paths fail before the
113        // analyzer is registered. Execution reads it again to support reloads
114        // and to make deletion/revocation visible to callers.
115        read_synonym_file(path)?;
116        Ok(TokenFilter::Synonym {
117            synonyms: BTreeMap::new(),
118            synonyms_path: Some(path.to_path_buf()),
119        })
120    }
121
122    /// Parse a synonym file into the same shape `Synonym::synonyms`
123    /// uses. Public so engines can pre-resolve a path to an inline map.
124    pub fn parse_synonym_file(
125        path: &Path,
126    ) -> Result<BTreeMap<String, Vec<String>>, SynonymFileError> {
127        let body = read_synonym_file(path)?;
128        Ok(parse_synonym_body(&body))
129    }
130}
131
132fn parse_synonym_body(body: &str) -> BTreeMap<String, Vec<String>> {
133    let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
134    for raw_line in body.lines() {
135        let line = raw_line.trim();
136        if line.is_empty() || line.starts_with('#') {
137            continue;
138        }
139        if let Some((lhs, rhs)) = line.split_once("=>") {
140            // One-way mapping: lhs members all expand to the rhs list.
141            let lhs_terms: Vec<String> = lhs
142                .split(',')
143                .map(str::trim)
144                .filter(|s| !s.is_empty())
145                .map(str::to_string)
146                .collect();
147            let rhs_terms: Vec<String> = rhs
148                .split(',')
149                .map(str::trim)
150                .filter(|s| !s.is_empty())
151                .map(str::to_string)
152                .collect();
153            for term in lhs_terms {
154                let entry = out.entry(term).or_default();
155                for r in &rhs_terms {
156                    if !entry.iter().any(|e| e == r) {
157                        entry.push(r.clone());
158                    }
159                }
160            }
161        } else {
162            // Equivalent group: each member expands to the others.
163            let members: Vec<String> = line
164                .split(',')
165                .map(str::trim)
166                .filter(|s| !s.is_empty())
167                .map(str::to_string)
168                .collect();
169            if members.len() < 2 {
170                continue;
171            }
172            for (i, term) in members.iter().enumerate() {
173                let entry = out.entry(term.clone()).or_default();
174                for (j, other) in members.iter().enumerate() {
175                    if i == j {
176                        continue;
177                    }
178                    if !entry.iter().any(|e| e == other) {
179                        entry.push(other.clone());
180                    }
181                }
182            }
183        }
184    }
185    out
186}
187
188fn default_stop_language() -> String {
189    "english".to_string()
190}
191
192impl TokenFilter {
193    pub fn filter(&self, tokens: Vec<String>) -> AnalysisResult<Vec<String>> {
194        let tokens = match self {
195            TokenFilter::Lowercase => tokens.into_iter().map(|t| t.to_lowercase()).collect(),
196            TokenFilter::Stop {
197                language,
198                custom_words,
199            } => {
200                let mut words: BTreeSet<&str> =
201                    builtin_stop_words(language).iter().copied().collect();
202                let custom: Vec<&str> = custom_words.iter().map(String::as_str).collect();
203                words.extend(custom);
204                tokens
205                    .into_iter()
206                    .filter(|t| !words.contains(t.as_str()))
207                    .collect()
208            }
209            TokenFilter::PorterStem => tokens.into_iter().map(|t| porter::stem(&t)).collect(),
210            TokenFilter::ASCIIFolding => tokens.into_iter().map(|t| ascii_fold(&t)).collect(),
211            TokenFilter::Synonym {
212                synonyms,
213                synonyms_path,
214            } => {
215                let resolved: BTreeMap<String, Vec<String>> = if let Some(path) = synonyms_path {
216                    TokenFilter::parse_synonym_file(path)?
217                } else {
218                    synonyms.clone()
219                };
220                let mut out = Vec::with_capacity(tokens.len());
221                for t in tokens {
222                    if let Some(extra) = resolved.get(&t) {
223                        out.push(t);
224                        out.extend(extra.iter().cloned());
225                    } else {
226                        out.push(t);
227                    }
228                }
229                out
230            }
231            TokenFilter::Ngram {
232                min_gram,
233                max_gram,
234                keep_short,
235            } => {
236                validate_gram_bounds("n-gram token filter", *min_gram, *max_gram)?;
237                let mut out = Vec::new();
238                for t in tokens {
239                    let chars: Vec<char> = t.chars().collect();
240                    if chars.len() < *min_gram {
241                        if *keep_short {
242                            out.push(t);
243                        }
244                        continue;
245                    }
246                    for n in *min_gram..=*max_gram {
247                        if chars.len() < n {
248                            continue;
249                        }
250                        for i in 0..=(chars.len() - n) {
251                            out.push(chars[i..i + n].iter().collect());
252                        }
253                    }
254                }
255                out
256            }
257            TokenFilter::EdgeNgram { min_gram, max_gram } => {
258                validate_gram_bounds("edge n-gram token filter", *min_gram, *max_gram)?;
259                let mut out = Vec::new();
260                for t in tokens {
261                    let chars: Vec<char> = t.chars().collect();
262                    let upper = (*max_gram).min(chars.len());
263                    for n in *min_gram..=upper {
264                        out.push(chars[..n].iter().collect());
265                    }
266                }
267                out
268            }
269            TokenFilter::Length {
270                min_length,
271                max_length,
272            } => tokens
273                .into_iter()
274                .filter(|t| {
275                    let len = t.chars().count();
276                    if len < *min_length {
277                        return false;
278                    }
279                    if *max_length > 0 && len > *max_length {
280                        return false;
281                    }
282                    true
283                })
284                .collect(),
285        };
286        Ok(tokens)
287    }
288}
289
290fn read_synonym_file(path: &Path) -> Result<String, SynonymFileError> {
291    fs::read_to_string(path).map_err(|source| {
292        if source.kind() == io::ErrorKind::NotFound {
293            SynonymFileError::NotFound(path.to_path_buf())
294        } else {
295            SynonymFileError::Io {
296                path: path.to_path_buf(),
297                source,
298            }
299        }
300    })
301}
302
303fn validate_gram_bounds(
304    component: &'static str,
305    min_gram: usize,
306    max_gram: usize,
307) -> AnalysisResult<()> {
308    if min_gram == 0 || max_gram < min_gram {
309        return Err(AnalysisError::InvalidGramBounds {
310            component,
311            min_gram,
312            max_gram,
313        });
314    }
315    Ok(())
316}
317
318fn ascii_fold(token: &str) -> String {
319    if token.is_ascii() {
320        return token.to_owned();
321    }
322    let mut out = String::with_capacity(token.len());
323    for ch in token.chars() {
324        if ch.is_ascii() {
325            out.push(ch);
326            continue;
327        }
328        let folded: String = ch.nfkd().filter(char::is_ascii).collect();
329        if folded.is_empty() {
330            // No ASCII equivalent (CJK, Korean, Arabic, etc.) — keep original.
331            out.push(ch);
332        } else {
333            out.push_str(&folded);
334        }
335    }
336    out
337}
338
339const ENGLISH_STOP_WORDS: &[&str] = &[
340    "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", "is", "it",
341    "no", "not", "of", "on", "or", "such", "that", "the", "their", "then", "there", "these",
342    "they", "this", "to", "was", "were", "will", "with", "would", "can", "could", "do", "does",
343    "did", "had", "has", "have", "he", "her", "him", "his", "how", "i", "its", "may", "me", "my",
344    "nor", "our", "own", "she", "should", "so", "some", "than", "too", "us", "very", "we", "what",
345    "when", "which", "who", "whom", "why", "you", "your",
346];
347
348fn builtin_stop_words(language: &str) -> &'static [&'static str] {
349    match language {
350        "english" => ENGLISH_STOP_WORDS,
351        _ => &[],
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn v(s: &[&str]) -> Vec<String> {
360        s.iter().map(|t| (*t).to_string()).collect()
361    }
362
363    #[test]
364    fn lowercase_lowers_each_token() {
365        let f = TokenFilter::Lowercase;
366        assert_eq!(
367            f.filter(v(&["Hello", "WORLD"])).unwrap(),
368            v(&["hello", "world"])
369        );
370    }
371
372    #[test]
373    fn stop_removes_english_stop_words() {
374        let f = TokenFilter::Stop {
375            language: "english".to_string(),
376            custom_words: vec![],
377        };
378        assert_eq!(
379            f.filter(v(&["the", "rust", "is", "fast"])).unwrap(),
380            v(&["rust", "fast"])
381        );
382    }
383
384    #[test]
385    fn stop_includes_custom_words() {
386        let f = TokenFilter::Stop {
387            language: "english".to_string(),
388            custom_words: vec!["foo".to_string()],
389        };
390        assert_eq!(f.filter(v(&["foo", "bar", "the"])).unwrap(), v(&["bar"]));
391    }
392
393    #[test]
394    fn porter_stem_runs() {
395        let f = TokenFilter::PorterStem;
396        assert_eq!(
397            f.filter(v(&["caresses", "ponies"])).unwrap(),
398            v(&["caress", "poni"])
399        );
400    }
401
402    #[test]
403    fn ascii_folding_strips_diacritics() {
404        let f = TokenFilter::ASCIIFolding;
405        assert_eq!(
406            f.filter(v(&["café", "naïve"])).unwrap(),
407            v(&["cafe", "naive"])
408        );
409    }
410
411    #[test]
412    fn ascii_folding_preserves_cjk() {
413        let f = TokenFilter::ASCIIFolding;
414        assert_eq!(f.filter(v(&["한글"])).unwrap(), v(&["한글"]));
415    }
416
417    #[test]
418    fn synonym_appends_alternatives() {
419        let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();
420        m.insert(
421            "car".to_string(),
422            vec!["auto".to_string(), "vehicle".to_string()],
423        );
424        let f = TokenFilter::Synonym {
425            synonyms: m,
426            synonyms_path: None,
427        };
428        assert_eq!(
429            f.filter(v(&["fast", "car"])).unwrap(),
430            v(&["fast", "car", "auto", "vehicle"])
431        );
432    }
433
434    #[test]
435    fn ngram_emits_substrings() {
436        let f = TokenFilter::Ngram {
437            min_gram: 2,
438            max_gram: 3,
439            keep_short: false,
440        };
441        assert_eq!(f.filter(v(&["abc"])).unwrap(), v(&["ab", "bc", "abc"]));
442    }
443
444    #[test]
445    fn ngram_drops_short_unless_keep_set() {
446        let f_drop = TokenFilter::Ngram {
447            min_gram: 3,
448            max_gram: 4,
449            keep_short: false,
450        };
451        assert!(f_drop.filter(v(&["ab"])).unwrap().is_empty());
452
453        let f_keep = TokenFilter::Ngram {
454            min_gram: 3,
455            max_gram: 4,
456            keep_short: true,
457        };
458        assert_eq!(f_keep.filter(v(&["ab"])).unwrap(), v(&["ab"]));
459    }
460
461    #[test]
462    fn edge_ngram_emits_prefixes() {
463        let f = TokenFilter::EdgeNgram {
464            min_gram: 1,
465            max_gram: 3,
466        };
467        assert_eq!(f.filter(v(&["abcd"])).unwrap(), v(&["a", "ab", "abc"]));
468    }
469
470    #[test]
471    fn length_bounds_token_size() {
472        let f = TokenFilter::Length {
473            min_length: 2,
474            max_length: 4,
475        };
476        assert_eq!(
477            f.filter(v(&["a", "ab", "abcd", "abcde"])).unwrap(),
478            v(&["ab", "abcd"])
479        );
480    }
481}