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    configuration: Analyzer,
24    char_filters: Vec<PreparedCharFilter<'static>>,
25    tokenizer: PreparedTokenizer,
26    token_filters: Vec<PreparedTokenFilter<'static>>,
27    normalization: crate::normalization::PreparedNormalization,
28}
29
30impl Analyzer {
31    /// Resolve immutable inputs and reuse prepared stages with the default resource owner.
32    ///
33    /// Each file-backed synonym stage resolves its map during compilation. The existing uncompiled analysis methods continue to reload it on every call.
34    ///
35    /// ```
36    /// use uqa_analysis::standard_analyzer;
37    /// let compiled = standard_analyzer("english").compile()?;
38    /// let result = compiled.analyze_tokens("The cats and")?;
39    /// assert_eq!(result.tokens()[0].term(), "cat");
40    /// assert_eq!(result.tokens()[0].offsets().unwrap().utf8, 4..8);
41    /// assert_eq!(result.tokens()[0].position_increment(), 2);
42    /// assert_eq!(result.final_position_increment(), 1);
43    /// assert_eq!(compiled.analyze("Dogs")?, ["dog"]);
44    /// # Ok::<(), uqa_analysis::AnalysisError>(())
45    /// ```
46    pub fn compile(&self) -> AnalysisResult<Arc<CompiledAnalyzer>> {
47        AnalyzerResources::default().compile(self)
48    }
49
50    /// Resolve and compile this configuration with an explicit bounded resource owner.
51    pub fn compile_with_resources(
52        &self,
53        resources: &AnalyzerResources,
54    ) -> AnalysisResult<Arc<CompiledAnalyzer>> {
55        resources.compile(self)
56    }
57}
58
59impl CompiledAnalyzer {
60    pub(crate) fn prepare(
61        descriptor: Arc<AnalyzerDescriptor>,
62        #[cfg(feature = "nori")] nori: crate::nori::pipeline::ResolvedNoriPipeline,
63        #[cfg(feature = "kuromoji")] kuromoji: crate::kuromoji::pipeline::ResolvedKuromojiPipeline,
64    ) -> AnalysisResult<Self> {
65        let config = descriptor.configuration()?;
66        let char_filters = config
67            .char_filters
68            .iter()
69            .map(|filter| filter.prepare().map(PreparedCharFilter::into_owned))
70            .collect::<AnalysisResult<_>>()?;
71        let tokenizer = match &config.tokenizer {
72            #[cfg(feature = "nori")]
73            crate::Tokenizer::Nori(_) => PreparedTokenizer::Nori(nori.tokenizer.clone().ok_or(
74                crate::AnalysisError::Descriptor("missing resolved Korean tokenizer"),
75            )?),
76            #[cfg(feature = "kuromoji")]
77            crate::Tokenizer::Kuromoji(_) => {
78                PreparedTokenizer::Kuromoji(kuromoji.tokenizer.clone().ok_or(
79                    crate::AnalysisError::Descriptor("missing resolved Japanese tokenizer"),
80                )?)
81            }
82            _ => config.tokenizer.prepare()?,
83        };
84        let token_filters = config
85            .token_filters
86            .iter()
87            .enumerate()
88            .map(|(index, filter)| {
89                #[cfg(not(feature = "kuromoji"))]
90                let _ = index;
91                #[cfg(feature = "nori")]
92                if let Some(filter) = nori.filter(filter)? {
93                    return Ok(PreparedTokenFilter::Nori(filter));
94                }
95                #[cfg(feature = "kuromoji")]
96                if let Some(filter) = kuromoji.filter(index, filter)? {
97                    return Ok(PreparedTokenFilter::Kuromoji(filter));
98                }
99                filter.prepare().map(PreparedTokenFilter::into_owned)
100            })
101            .collect::<AnalysisResult<_>>()?;
102        let normalization = crate::normalization::PreparedNormalization::new(
103            config.normalization.as_ref(),
104            #[cfg(feature = "nori")]
105            nori.normalizer,
106            #[cfg(feature = "kuromoji")]
107            kuromoji.normalizer,
108        )?;
109        Ok(Self {
110            descriptor,
111            configuration: config,
112            char_filters,
113            tokenizer,
114            token_filters,
115            normalization,
116        })
117    }
118
119    pub fn descriptor(&self) -> &Arc<AnalyzerDescriptor> {
120        &self.descriptor
121    }
122
123    /// Borrow the resolved diagnostic configuration retained with this immutable revision.
124    ///
125    /// This shares preparation's configuration without decoding the descriptor or resolving external resources again.
126    pub fn configuration(&self) -> &Analyzer {
127        &self.configuration
128    }
129
130    /// Analyze with independent output/source state and no expression compilation or file resolution.
131    pub fn analyze_tokens(&self, text: &str) -> AnalysisResult<AnalyzedText> {
132        Ok(self
133            .analyze_tokens_budgeted(text, &MemoryBudget::new(usize::MAX), || Ok(()))?
134            .into_parts()
135            .0)
136    }
137
138    /// Execute all prepared stages with one allowance for runtime buffers and retained output.
139    ///
140    /// 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.
141    ///
142    /// ```
143    /// use uqa_analysis::standard_analyzer;
144    /// use uqa_core::memory::MemoryBudget;
145    /// let compiled = standard_analyzer("english").compile()?;
146    /// let budget = MemoryBudget::new(64 * 1024);
147    /// let result = compiled.analyze_tokens_budgeted("The cats and", &budget, || Ok(()))?;
148    /// assert_eq!(result.tokens()[0].term(), "cat");
149    /// assert_eq!(result.final_position_increment(), 1);
150    /// drop(result);
151    /// assert_eq!(budget.used(), 0);
152    /// # Ok::<(), uqa_analysis::AnalysisError>(())
153    /// ```
154    pub fn analyze_tokens_budgeted(
155        &self,
156        text: &str,
157        budget: &MemoryBudget,
158        mut poll: impl FnMut() -> AnalysisResult<()>,
159    ) -> AnalysisResult<Budgeted<AnalyzedText>> {
160        poll()?;
161        let mut filtered = FilteredText::new(text);
162        for filter in &self.char_filters {
163            filtered = filter.filter_mapped_budgeted(filtered, budget, &mut poll)?;
164        }
165        let mut tokens = self
166            .tokenizer
167            .tokenize_mapped_for_filters_budgeted(&filtered, budget, &mut poll)?;
168        drop(filtered);
169        for filter in &self.token_filters {
170            tokens = filter.filter_analyzed_budgeted(tokens, &mut poll)?;
171        }
172        #[cfg(feature = "kuromoji")]
173        if matches!(self.tokenizer, PreparedTokenizer::Kuromoji(_)) {
174            tokens.validate_japanese_attributes(&mut poll)?;
175        }
176        poll()?;
177        Ok(tokens)
178    }
179
180    pub fn analyze(&self, text: &str) -> AnalysisResult<Vec<String>> {
181        self.analyze_tokens(text)?.into_terms()
182    }
183
184    /// Normalize complete input with its retained plan, independently of analysis stages.
185    pub fn normalize(&self, text: &str) -> AnalysisResult<String> {
186        Ok(self
187            .normalize_budgeted(text, &MemoryBudget::new(usize::MAX), || Ok(()))?
188            .into_parts()
189            .0)
190    }
191
192    /// Normalize complete text with one retained output allowance and cancellation callback.
193    pub fn normalize_budgeted(
194        &self,
195        text: &str,
196        budget: &MemoryBudget,
197        mut poll: impl FnMut() -> AnalysisResult<()>,
198    ) -> AnalysisResult<Budgeted<String>> {
199        self.normalization
200            .normalize_budgeted(text, budget, &mut poll)
201    }
202}