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;
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16use crate::{AnalysisError, AnalysisResult};
17
18mod ascii;
19mod compiled;
20pub(crate) mod lowercase;
21mod stream;
22mod synonyms;
23pub(crate) use compiled::PreparedTokenFilter;
24use synonyms::parse_synonym_body;
25pub(crate) use synonyms::parse_synonym_body_bounded;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "type", rename_all = "snake_case")]
29pub enum TokenFilter {
30    #[cfg(feature = "nori")]
31    #[serde(rename = "nori_part_of_speech")]
32    NoriPartOfSpeech(crate::nori::NoriPOSConfig),
33    #[cfg(feature = "nori")]
34    #[serde(rename = "nori_readingform")]
35    NoriReadingForm(crate::nori::EmptyFilterConfig),
36    #[cfg(feature = "nori")]
37    #[serde(rename = "unicode_simple_lowercase")]
38    UnicodeSimpleLowercase(crate::nori::SimpleLowercaseConfig),
39    #[cfg(feature = "nori")]
40    #[serde(rename = "nori_number")]
41    NoriNumber(crate::nori::EmptyFilterConfig),
42    Lowercase,
43    Stop {
44        #[serde(default = "default_stop_language")]
45        language: String,
46        #[serde(default)]
47        custom_words: Vec<String>,
48    },
49    PorterStem,
50    // The alias keeps catalogs persisted before the stable tag existed
51    // deserializable: releases up to 0.1.2 wrote the derived spelling.
52    #[serde(rename = "ascii_folding", alias = "a_s_c_i_i_folding")]
53    ASCIIFolding,
54    Synonym {
55        /// Inline `term -> [expansion, ...]` mapping. Empty when the
56        /// filter sources its mappings from `synonyms_path` instead.
57        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
58        synonyms: BTreeMap<String, Vec<String>>,
59        /// Path to a Solr / Elasticsearch-style synonym file. The file
60        /// is parsed every time the filter runs so reload-on-edit is
61        /// free; for production use cache the parsed map upstream.
62        /// Optional path to a reloadable synonym map.
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        synonyms_path: Option<PathBuf>,
65    },
66    Ngram {
67        min_gram: usize,
68        max_gram: usize,
69        #[serde(default)]
70        keep_short: bool,
71    },
72    EdgeNgram {
73        min_gram: usize,
74        max_gram: usize,
75    },
76    Length {
77        #[serde(default)]
78        min_length: usize,
79        #[serde(default)]
80        max_length: usize,
81    },
82}
83
84/// Errors raised when constructing a `Synonym` filter from a file.
85#[derive(Debug, thiserror::Error)]
86pub enum SynonymFileError {
87    #[error("synonym file not found: {0}")]
88    NotFound(PathBuf),
89    #[error("failed to read synonym file `{path}`: {source}")]
90    Io {
91        path: PathBuf,
92        #[source]
93        source: io::Error,
94    },
95}
96
97impl TokenFilter {
98    /// Validate configuration without filtering tokens. File-backed synonym
99    /// filters are read here so registration rejects missing/unreadable paths;
100    /// [`Self::filter`] reads them again on every execution to detect later
101    /// deletion, permission changes, and edits.
102    pub fn validate(&self) -> AnalysisResult<()> {
103        match self {
104            #[cfg(feature = "nori")]
105            TokenFilter::UnicodeSimpleLowercase(_) => self.prepare().map(|_| ()),
106            TokenFilter::Synonym {
107                synonyms_path: Some(_),
108                ..
109            }
110            | TokenFilter::Ngram { .. }
111            | TokenFilter::EdgeNgram { .. } => self.prepare().map(|_| ()),
112            _ => Ok(()),
113        }
114    }
115
116    /// Build a `Synonym` filter from a Solr or Elasticsearch synonym file.
117    /// In this format,
118    /// blank lines and `#` comments are skipped, `a => b, c` defines a
119    /// one-way mapping, and `a, b, c` defines an equivalent group
120    /// where every term expands to the other group members.
121    pub fn synonym_from_path<P: AsRef<Path>>(path: P) -> Result<Self, SynonymFileError> {
122        let path = path.as_ref();
123        if !path.exists() {
124            return Err(SynonymFileError::NotFound(path.to_path_buf()));
125        }
126        // Read once at construction so unreadable paths fail before the
127        // analyzer is registered. Execution reads it again to support reloads
128        // and to make deletion/revocation visible to callers.
129        read_synonym_file(path)?;
130        Ok(TokenFilter::Synonym {
131            synonyms: BTreeMap::new(),
132            synonyms_path: Some(path.to_path_buf()),
133        })
134    }
135
136    /// Parse a synonym file into the same shape `Synonym::synonyms`
137    /// uses. Public so engines can pre-resolve a path to an inline map.
138    pub fn parse_synonym_file(
139        path: &Path,
140    ) -> Result<BTreeMap<String, Vec<String>>, SynonymFileError> {
141        let body = read_synonym_file(path)?;
142        Ok(parse_synonym_body(&body))
143    }
144}
145
146fn default_stop_language() -> String {
147    "english".to_string()
148}
149
150impl TokenFilter {
151    pub fn filter(&self, tokens: Vec<String>) -> AnalysisResult<Vec<String>> {
152        stream::filter(
153            &self.prepare()?,
154            crate::token::TokenBatch::from_terms(tokens),
155        )?
156        .into_terms()
157    }
158
159    /// Transform tokens while retaining their source spans and graph end state.
160    pub fn filter_analyzed(
161        &self,
162        input: crate::AnalyzedText,
163    ) -> AnalysisResult<crate::AnalyzedText> {
164        self.prepare()?.filter_analyzed(input)
165    }
166
167    /// Consume a reserved stream and retain its allowance through every common or Korean token filter.
168    ///
169    /// The returned tokens retain their own terms, morphology, terminal state and vector reservations, and share existing source leases. Removed buffers release their reservations after destruction; replacements and copies reserve before allocation. Byte-limit and callback errors return no partial result. Immutable filter preparation and caller-owned configuration have separate ownership.
170    ///
171    /// ```
172    /// use uqa_analysis::{TokenFilter, Tokenizer};
173    /// use uqa_core::memory::MemoryBudget;
174    /// let budget = MemoryBudget::new(64 * 1024);
175    /// let tokens = Tokenizer::Whitespace.tokenize_with_offsets_budgeted(
176    ///     "UQA AND", &budget, || Ok(()),
177    /// )?;
178    /// let output = TokenFilter::Lowercase.filter_analyzed_budgeted(tokens, || Ok(()))?;
179    /// assert_eq!(output.tokens()[0].term(), "uqa");
180    /// drop(output);
181    /// assert_eq!(budget.used(), 0);
182    /// # Ok::<(), uqa_analysis::AnalysisError>(())
183    /// ```
184    pub fn filter_analyzed_budgeted(
185        &self,
186        input: uqa_core::memory::Budgeted<crate::AnalyzedText>,
187        mut poll: impl FnMut() -> AnalysisResult<()>,
188    ) -> AnalysisResult<uqa_core::memory::Budgeted<crate::AnalyzedText>> {
189        poll()?;
190        self.prepare()?.filter_analyzed_budgeted(input, &mut poll)
191    }
192}
193
194fn read_synonym_file(path: &Path) -> Result<String, SynonymFileError> {
195    fs::read_to_string(path).map_err(|source| {
196        if source.kind() == io::ErrorKind::NotFound {
197            SynonymFileError::NotFound(path.to_path_buf())
198        } else {
199            SynonymFileError::Io {
200                path: path.to_path_buf(),
201                source,
202            }
203        }
204    })
205}
206
207fn validate_gram_bounds(
208    component: &'static str,
209    min_gram: usize,
210    max_gram: usize,
211) -> AnalysisResult<()> {
212    if min_gram == 0 || max_gram < min_gram {
213        return Err(AnalysisError::InvalidGramBounds {
214            component,
215            min_gram,
216            max_gram,
217        });
218    }
219    Ok(())
220}
221
222const ENGLISH_STOP_WORDS: &[&str] = &[
223    "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", "is", "it",
224    "no", "not", "of", "on", "or", "such", "that", "the", "their", "then", "there", "these",
225    "they", "this", "to", "was", "were", "will", "with", "would", "can", "could", "do", "does",
226    "did", "had", "has", "have", "he", "her", "him", "his", "how", "i", "its", "may", "me", "my",
227    "nor", "our", "own", "she", "should", "so", "some", "than", "too", "us", "very", "we", "what",
228    "when", "which", "who", "whom", "why", "you", "your",
229];
230
231pub(crate) fn builtin_stop_words(language: &str) -> &'static [&'static str] {
232    match language {
233        "english" => ENGLISH_STOP_WORDS,
234        _ => &[],
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn v(s: &[&str]) -> Vec<String> {
243        s.iter().map(|t| (*t).to_string()).collect()
244    }
245
246    #[test]
247    fn lowercase_lowers_each_token() {
248        let f = TokenFilter::Lowercase;
249        assert_eq!(
250            f.filter(v(&["Hello", "WORLD"])).unwrap(),
251            v(&["hello", "world"])
252        );
253    }
254
255    #[test]
256    fn stop_removes_english_stop_words() {
257        let f = TokenFilter::Stop {
258            language: "english".to_string(),
259            custom_words: vec![],
260        };
261        assert_eq!(
262            f.filter(v(&["the", "rust", "is", "fast"])).unwrap(),
263            v(&["rust", "fast"])
264        );
265    }
266
267    #[test]
268    fn stop_includes_custom_words() {
269        let f = TokenFilter::Stop {
270            language: "english".to_string(),
271            custom_words: vec!["foo".to_string()],
272        };
273        assert_eq!(f.filter(v(&["foo", "bar", "the"])).unwrap(), v(&["bar"]));
274    }
275
276    #[test]
277    fn porter_stem_runs() {
278        let f = TokenFilter::PorterStem;
279        assert_eq!(
280            f.filter(v(&["caresses", "ponies"])).unwrap(),
281            v(&["caress", "poni"])
282        );
283    }
284
285    #[test]
286    fn ascii_folding_strips_diacritics() {
287        let f = TokenFilter::ASCIIFolding;
288        assert_eq!(
289            f.filter(v(&["café", "naïve"])).unwrap(),
290            v(&["cafe", "naive"])
291        );
292    }
293
294    #[test]
295    fn ascii_folding_preserves_cjk() {
296        let f = TokenFilter::ASCIIFolding;
297        assert_eq!(f.filter(v(&["한글"])).unwrap(), v(&["한글"]));
298    }
299
300    #[test]
301    fn synonym_appends_alternatives() {
302        let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();
303        m.insert(
304            "car".to_string(),
305            vec!["auto".to_string(), "vehicle".to_string()],
306        );
307        let f = TokenFilter::Synonym {
308            synonyms: m,
309            synonyms_path: None,
310        };
311        assert_eq!(
312            f.filter(v(&["fast", "car"])).unwrap(),
313            v(&["fast", "car", "auto", "vehicle"])
314        );
315    }
316
317    #[test]
318    fn ngram_emits_substrings() {
319        let f = TokenFilter::Ngram {
320            min_gram: 2,
321            max_gram: 3,
322            keep_short: false,
323        };
324        assert_eq!(f.filter(v(&["abc"])).unwrap(), v(&["ab", "bc", "abc"]));
325    }
326
327    #[test]
328    fn ngram_drops_short_unless_keep_set() {
329        let f_drop = TokenFilter::Ngram {
330            min_gram: 3,
331            max_gram: 4,
332            keep_short: false,
333        };
334        assert!(f_drop.filter(v(&["ab"])).unwrap().is_empty());
335
336        let f_keep = TokenFilter::Ngram {
337            min_gram: 3,
338            max_gram: 4,
339            keep_short: true,
340        };
341        assert_eq!(f_keep.filter(v(&["ab"])).unwrap(), v(&["ab"]));
342    }
343
344    #[test]
345    fn edge_ngram_emits_prefixes() {
346        let f = TokenFilter::EdgeNgram {
347            min_gram: 1,
348            max_gram: 3,
349        };
350        assert_eq!(f.filter(v(&["abcd"])).unwrap(), v(&["a", "ab", "abc"]));
351    }
352
353    #[test]
354    fn length_bounds_token_size() {
355        let f = TokenFilter::Length {
356            min_length: 2,
357            max_length: 4,
358        };
359        assert_eq!(
360            f.filter(v(&["a", "ab", "abcd", "abcde"])).unwrap(),
361            v(&["ab", "abcd"])
362        );
363    }
364}