Skip to main content

uqa_analysis/analyzer/
compiled.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Immutable prepared pipelines keep execution state local to each analyzed input.
8
9use std::sync::Arc;
10use uqa_core::memory::{Budgeted, MemoryBudget};
11
12use super::Analyzer;
13use crate::{
14    char_filter::PreparedCharFilter, token_filter::PreparedTokenFilter,
15    tokenizer::PreparedTokenizer, AnalysisResult, AnalyzedText, AnalyzerDescriptor,
16    AnalyzerResources, FilteredText,
17};
18
19/// A frozen pipeline with compiled expressions, fixed stop sets, and resolved synonym maps.
20#[derive(Debug)]
21pub struct CompiledAnalyzer {
22    descriptor: Arc<AnalyzerDescriptor>,
23    char_filters: Vec<PreparedCharFilter<'static>>,
24    tokenizer: PreparedTokenizer,
25    token_filters: Vec<PreparedTokenFilter<'static>>,
26    normalization: crate::normalization::PreparedNormalization,
27}
28
29impl Analyzer {
30    /// Resolve immutable inputs and reuse prepared stages with the default resource owner.
31    ///
32    /// Each file-backed synonym stage resolves its map during compilation. The existing uncompiled analysis methods continue to reload it on every call.
33    ///
34    /// ```
35    /// use uqa_analysis::standard_analyzer;
36    /// let compiled = standard_analyzer("english").compile()?;
37    /// let result = compiled.analyze_tokens("The cats and")?;
38    /// assert_eq!(result.tokens()[0].term(), "cat");
39    /// assert_eq!(result.tokens()[0].offsets().unwrap().utf8, 4..8);
40    /// assert_eq!(result.tokens()[0].position_increment(), 2);
41    /// assert_eq!(result.final_position_increment(), 1);
42    /// assert_eq!(compiled.analyze("Dogs")?, ["dog"]);
43    /// # Ok::<(), uqa_analysis::AnalysisError>(())
44    /// ```
45    pub fn compile(&self) -> AnalysisResult<Arc<CompiledAnalyzer>> {
46        AnalyzerResources::default().compile(self)
47    }
48
49    /// Resolve and compile this configuration with an explicit bounded resource owner.
50    pub fn compile_with_resources(
51        &self,
52        resources: &AnalyzerResources,
53    ) -> AnalysisResult<Arc<CompiledAnalyzer>> {
54        resources.compile(self)
55    }
56}
57
58impl CompiledAnalyzer {
59    pub(crate) fn prepare(
60        descriptor: Arc<AnalyzerDescriptor>,
61        #[cfg(feature = "nori")] nori: crate::nori::pipeline::ResolvedNoriPipeline,
62        #[cfg(feature = "kuromoji")] kuromoji: crate::kuromoji::pipeline::ResolvedKuromojiPipeline,
63    ) -> AnalysisResult<Self> {
64        let config = descriptor.configuration()?;
65        let char_filters = config
66            .char_filters
67            .iter()
68            .map(|filter| filter.prepare().map(PreparedCharFilter::into_owned))
69            .collect::<AnalysisResult<_>>()?;
70        let tokenizer = match &config.tokenizer {
71            #[cfg(feature = "nori")]
72            crate::Tokenizer::Nori(_) => PreparedTokenizer::Nori(nori.tokenizer.clone().ok_or(
73                crate::AnalysisError::Descriptor("missing resolved Korean tokenizer"),
74            )?),
75            #[cfg(feature = "kuromoji")]
76            crate::Tokenizer::Kuromoji(_) => {
77                PreparedTokenizer::Kuromoji(kuromoji.tokenizer.clone().ok_or(
78                    crate::AnalysisError::Descriptor("missing resolved Japanese tokenizer"),
79                )?)
80            }
81            _ => config.tokenizer.prepare()?,
82        };
83        let token_filters = config
84            .token_filters
85            .iter()
86            .enumerate()
87            .map(|(index, filter)| {
88                #[cfg(not(feature = "kuromoji"))]
89                let _ = index;
90                #[cfg(feature = "nori")]
91                if let Some(filter) = nori.filter(filter)? {
92                    return Ok(PreparedTokenFilter::Nori(filter));
93                }
94                #[cfg(feature = "kuromoji")]
95                if let Some(filter) = kuromoji.filter(index, filter)? {
96                    return Ok(PreparedTokenFilter::Kuromoji(filter));
97                }
98                filter.prepare().map(PreparedTokenFilter::into_owned)
99            })
100            .collect::<AnalysisResult<_>>()?;
101        Ok(Self {
102            descriptor,
103            char_filters,
104            tokenizer,
105            token_filters,
106            normalization: crate::normalization::PreparedNormalization::new(
107                config.normalization.as_ref(),
108                #[cfg(feature = "nori")]
109                nori.normalizer,
110                #[cfg(feature = "kuromoji")]
111                kuromoji.normalizer,
112            )?,
113        })
114    }
115
116    pub fn descriptor(&self) -> &Arc<AnalyzerDescriptor> {
117        &self.descriptor
118    }
119
120    /// Analyze with independent output/source state and no expression compilation or file resolution.
121    pub fn analyze_tokens(&self, text: &str) -> AnalysisResult<AnalyzedText> {
122        Ok(self
123            .analyze_tokens_budgeted(text, &MemoryBudget::new(usize::MAX), || Ok(()))?
124            .into_parts()
125            .0)
126    }
127
128    /// Execute all prepared stages with one allowance for runtime buffers and retained output.
129    ///
130    /// Immutable compiled resources and borrowed input have separate owners. Character maps, tokenization, common/Korean filters and source projections share this allowance. Errors return no partial output. Prepared regex automata retain analysis-owned search workspaces and poll during traversal and capture resolution.
131    ///
132    /// ```
133    /// use uqa_analysis::standard_analyzer;
134    /// use uqa_core::memory::MemoryBudget;
135    /// let compiled = standard_analyzer("english").compile()?;
136    /// let budget = MemoryBudget::new(64 * 1024);
137    /// let result = compiled.analyze_tokens_budgeted("The cats and", &budget, || Ok(()))?;
138    /// assert_eq!(result.tokens()[0].term(), "cat");
139    /// assert_eq!(result.final_position_increment(), 1);
140    /// drop(result);
141    /// assert_eq!(budget.used(), 0);
142    /// # Ok::<(), uqa_analysis::AnalysisError>(())
143    /// ```
144    pub fn analyze_tokens_budgeted(
145        &self,
146        text: &str,
147        budget: &MemoryBudget,
148        mut poll: impl FnMut() -> AnalysisResult<()>,
149    ) -> AnalysisResult<Budgeted<AnalyzedText>> {
150        poll()?;
151        let mut filtered = FilteredText::new(text);
152        for filter in &self.char_filters {
153            filtered = filter.filter_mapped_budgeted(filtered, budget, &mut poll)?;
154        }
155        let mut tokens = self
156            .tokenizer
157            .tokenize_mapped_for_filters_budgeted(&filtered, budget, &mut poll)?;
158        drop(filtered);
159        for filter in &self.token_filters {
160            tokens = filter.filter_analyzed_budgeted(tokens, &mut poll)?;
161        }
162        #[cfg(feature = "kuromoji")]
163        if matches!(self.tokenizer, PreparedTokenizer::Kuromoji(_)) {
164            tokens.validate_japanese_attributes(&mut poll)?;
165        }
166        poll()?;
167        Ok(tokens)
168    }
169
170    pub fn analyze(&self, text: &str) -> AnalysisResult<Vec<String>> {
171        self.analyze_tokens(text)?.into_terms()
172    }
173
174    /// Normalize complete input with its retained plan, independently of analysis stages.
175    pub fn normalize(&self, text: &str) -> AnalysisResult<String> {
176        Ok(self
177            .normalize_budgeted(text, &MemoryBudget::new(usize::MAX), || Ok(()))?
178            .into_parts()
179            .0)
180    }
181
182    /// Normalize complete text with one retained output allowance and cancellation callback.
183    pub fn normalize_budgeted(
184        &self,
185        text: &str,
186        budget: &MemoryBudget,
187        mut poll: impl FnMut() -> AnalysisResult<()>,
188    ) -> AnalysisResult<Budgeted<String>> {
189        self.normalization
190            .normalize_budgeted(text, budget, &mut poll)
191    }
192}