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