Skip to main content

uqa_storage/
inverted_index.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Inverted index abstraction and an in-memory implementation.
8//!
9//! The index maps `(field, term)` keys to posting lists, tracks per-field
10//! token lengths and corpus statistics, and indexes documents by running
11//! an [`Analyzer`] over each field's text.
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::sync::Arc;
15
16use uqa_analysis::Analyzer;
17use uqa_core::{DocId, FieldName, IndexStats, Payload, PostingEntry, PostingList, TokenOccurrence};
18
19use crate::TokenTermKey;
20
21use crate::backend::{StorageBackendError, StorageBackendResult};
22use crate::block_max_index::BlockMaxScorer;
23use crate::clustered_postings::{MaterializedPostingCursor, PostingCursor, PostingScore};
24
25mod analysis;
26mod batch;
27mod bindings;
28mod contract;
29mod memory;
30mod metadata;
31mod read_cursor;
32
33#[cfg(test)]
34mod tests;
35
36pub use analysis::{
37    analyze_index_field, analyze_index_field_cancellable, analyze_query_graph,
38    analyze_query_graph_budgeted, analyze_query_terms, analyze_query_terms_budgeted, AnalyzedField,
39    IndexedFieldMetadata,
40};
41pub use bindings::AnalyzerBindings;
42pub use contract::{AnalyzerPhase, InvertedIndex};
43pub use metadata::IndexedFieldRevision;
44
45/// Linear term/position stores cannot install morphology without immutable graph revisions.
46pub fn validate_linear_analyzer(analyzer: &Analyzer) -> StorageBackendResult<()> {
47    if analyzer.uses_korean_stages() {
48        return Err(StorageBackendError::Other("Korean analyzers require immutable analyzer revisions and lossless token-graph storage".into()));
49    }
50    if analyzer.uses_japanese_stages() {
51        return Err(StorageBackendError::Other("Japanese analyzers require immutable analyzer revisions and lossless token-graph storage".into()));
52    }
53    Ok(())
54}
55
56/// A linear store must not accept a revision whose declared normalization policy it cannot preserve.
57pub fn validate_linear_revision(
58    revision: &uqa_analysis::CompiledAnalyzer,
59) -> StorageBackendResult<()> {
60    validate_linear_analyzer(&revision.descriptor().configuration()?)?;
61    if revision.descriptor().length_policy() != uqa_analysis::TokenLengthPolicy::EmittedTokens {
62        return Err(StorageBackendError::Other(
63            "overlap-discounted analyzer revisions require occurrence storage".into(),
64        ));
65    }
66    Ok(())
67}
68
69fn counter_error(context: &str) -> StorageBackendError {
70    StorageBackendError::Other(format!("inverted-index {context} overflow or corruption"))
71}
72
73fn usize_to_u64(value: usize, context: &str) -> StorageBackendResult<u64> {
74    u64::try_from(value).map_err(|_| counter_error(context))
75}
76
77fn checked_sum_u64(
78    values: impl IntoIterator<Item = u64>,
79    context: &str,
80) -> StorageBackendResult<u64> {
81    values.into_iter().try_fold(0_u64, |total, value| {
82        total
83            .checked_add(value)
84            .ok_or_else(|| counter_error(context))
85    })
86}
87
88#[derive(Debug)]
89pub struct MemoryInvertedIndex {
90    bindings: AnalyzerBindings,
91    /// Snapshots share immutable corpus state; validated writes detach it once.
92    state: Arc<MemoryIndexState>,
93}
94
95/// Cloning retains the existing independently owned corpus maps; snapshot APIs share them until a write.
96impl Clone for MemoryInvertedIndex {
97    fn clone(&self) -> Self {
98        Self {
99            bindings: self.bindings.clone(),
100            state: Arc::new((*self.state).clone()),
101        }
102    }
103}
104
105#[derive(Debug, Clone, Default)]
106struct MemoryIndexState {
107    /// `(field, term) -> doc_id -> entry (positions inside the doc)`
108    index: BTreeMap<PostingKey, BTreeMap<DocId, MemoryPosting>>,
109    /// Reverse index for `remove_document` so we touch only relevant
110    /// `(field, term)` posting maps instead of scanning the whole index.
111    doc_terms: BTreeMap<DocId, BTreeSet<PostingKey>>,
112    /// Exact revision, normalization length, and original source end state for each document field.
113    doc_fields: BTreeMap<DocId, BTreeMap<FieldName, IndexedFieldMetadata>>,
114    /// Sum of field lengths across all docs, per field.
115    total_length: BTreeMap<FieldName, u64>,
116    /// Number of documents with indexed content per field, maintained
117    /// incrementally so per-query BM25 statistics never walk
118    /// `doc_fields` (O(corpus) at query time otherwise).
119    field_doc_counts: BTreeMap<FieldName, u64>,
120    doc_count: u64,
121}
122
123type PostingKey = (FieldName, TokenTermKey);
124
125#[derive(Debug, Clone)]
126struct MemoryPosting {
127    projection: PostingEntry,
128    occurrences: Vec<TokenOccurrence>,
129}
130
131struct StagedMemoryDocument {
132    fields: BTreeMap<FieldName, IndexedFieldMetadata>,
133    terms: BTreeSet<PostingKey>,
134    postings: Vec<(PostingKey, MemoryPosting)>,
135}
136
137struct MemoryReplacementPlan {
138    old_terms: BTreeSet<PostingKey>,
139    next_doc_count: u64,
140    field_counters: BTreeMap<FieldName, (u64, u64)>,
141}
142
143impl MemoryInvertedIndex {
144    pub fn new(analyzer: Analyzer) -> Self {
145        Self::with_bindings(AnalyzerBindings::new(analyzer))
146    }
147
148    fn with_bindings(bindings: AnalyzerBindings) -> Self {
149        Self {
150            bindings,
151            state: Arc::default(),
152        }
153    }
154
155    fn shared_snapshot(&self) -> Self {
156        Self {
157            bindings: self.bindings.clone(),
158            state: Arc::clone(&self.state),
159        }
160    }
161
162    fn stage_document(
163        &self,
164        doc_id: DocId,
165        fields: BTreeMap<FieldName, String>,
166    ) -> StorageBackendResult<StagedMemoryDocument> {
167        self.stage_document_inner(doc_id, fields, None)
168    }
169
170    fn stage_document_inner(
171        &self,
172        doc_id: DocId,
173        fields: BTreeMap<FieldName, String>,
174        cancellation: Option<&uqa_core::CancellationToken>,
175    ) -> StorageBackendResult<StagedMemoryDocument> {
176        let mut metadata = BTreeMap::new();
177        let mut terms = BTreeSet::new();
178        let mut postings = Vec::new();
179        for (field, text) in fields {
180            if let Some(cancellation) = cancellation {
181                cancellation.check()?;
182            }
183            let revision = self.bindings.index_revision(&field)?;
184            let analyzed = match cancellation {
185                Some(cancellation) => {
186                    analyze_index_field_cancellable(&revision, &text, cancellation)?
187                }
188                None => analyze_index_field(&revision, &text)?,
189            };
190            metadata.insert(
191                field.clone(),
192                IndexedFieldMetadata::new(&revision, &analyzed),
193            );
194            for (term, occurrences) in analyzed.terms {
195                if let Some(cancellation) = cancellation {
196                    cancellation.check()?;
197                }
198                let mut positions: Vec<_> = occurrences.iter().map(|item| item.position).collect();
199                positions.sort_unstable();
200                positions.dedup();
201                let key = (field.clone(), term);
202                terms.insert(key.clone());
203                postings.push((
204                    key,
205                    MemoryPosting {
206                        projection: PostingEntry::new(
207                            doc_id,
208                            Payload {
209                                positions,
210                                score: 0.0,
211                                fields: BTreeMap::new(),
212                            },
213                        ),
214                        occurrences,
215                    },
216                ));
217            }
218        }
219        Ok(StagedMemoryDocument {
220            fields: metadata,
221            terms,
222            postings,
223        })
224    }
225}
226
227impl MemoryIndexState {
228    fn plan_replacement(
229        &self,
230        doc_id: DocId,
231        new_fields: &BTreeMap<FieldName, IndexedFieldMetadata>,
232    ) -> StorageBackendResult<MemoryReplacementPlan> {
233        let has_terms = self.doc_terms.contains_key(&doc_id);
234        if has_terms != self.doc_fields.contains_key(&doc_id) {
235            return Err(StorageBackendError::Other(format!(
236                "inverted-index document {doc_id} has inconsistent reverse-index state"
237            )));
238        }
239        let old_terms = self.doc_terms.get(&doc_id).cloned().unwrap_or_default();
240        let old_fields = self.doc_fields.get(&doc_id).cloned().unwrap_or_default();
241        let next_doc_count = self
242            .doc_count
243            .checked_sub(u64::from(has_terms))
244            .ok_or_else(|| counter_error("document count"))?
245            .checked_add(u64::from(!new_fields.is_empty()))
246            .ok_or_else(|| counter_error("document count"))?;
247        for key in &old_terms {
248            if !self
249                .index
250                .get(key)
251                .is_some_and(|postings| postings.contains_key(&doc_id))
252            {
253                return Err(StorageBackendError::Other(format!(
254                    "inverted-index document {doc_id} references a missing posting"
255                )));
256            }
257        }
258
259        let mut affected_fields = BTreeSet::new();
260        affected_fields.extend(old_fields.keys().cloned());
261        affected_fields.extend(new_fields.keys().cloned());
262        let mut field_counters = BTreeMap::new();
263        for field in affected_fields {
264            let old_length = old_fields.get(&field).map_or(0, |metadata| metadata.length);
265            let new_length = new_fields.get(&field).map_or(0, |metadata| metadata.length);
266            let total = self
267                .total_length
268                .get(&field)
269                .copied()
270                .unwrap_or(0)
271                .checked_sub(old_length)
272                .ok_or_else(|| counter_error("total field length"))?
273                .checked_add(new_length)
274                .ok_or_else(|| counter_error("total field length"))?;
275            let field_docs = self
276                .field_doc_counts
277                .get(&field)
278                .copied()
279                .unwrap_or(0)
280                .checked_sub(u64::from(old_fields.contains_key(&field)))
281                .ok_or_else(|| counter_error("field document count"))?
282                .checked_add(u64::from(new_fields.contains_key(&field)))
283                .ok_or_else(|| counter_error("field document count"))?;
284            field_counters.insert(field, (total, field_docs));
285        }
286        Ok(MemoryReplacementPlan {
287            old_terms,
288            next_doc_count,
289            field_counters,
290        })
291    }
292
293    fn apply_replacement(
294        &mut self,
295        doc_id: DocId,
296        staged: StagedMemoryDocument,
297        plan: MemoryReplacementPlan,
298    ) -> StorageBackendResult<()> {
299        for key in plan.old_terms {
300            let postings = self.index.get_mut(&key).ok_or_else(|| {
301                StorageBackendError::Other(format!(
302                    "inverted-index document {doc_id} lost a validated posting before replacement"
303                ))
304            })?;
305            postings.remove(&doc_id);
306            if postings.is_empty() {
307                self.index.remove(&key);
308            }
309        }
310        self.doc_fields.remove(&doc_id);
311        self.doc_terms.remove(&doc_id);
312        for (field, (total, field_docs)) in plan.field_counters {
313            if field_docs == 0 {
314                self.total_length.remove(&field);
315                self.field_doc_counts.remove(&field);
316            } else {
317                self.total_length.insert(field.clone(), total);
318                self.field_doc_counts.insert(field, field_docs);
319            }
320        }
321        for (key, entry) in staged.postings {
322            self.index.entry(key).or_default().insert(doc_id, entry);
323        }
324        self.doc_count = plan.next_doc_count;
325        if !staged.fields.is_empty() {
326            self.doc_fields.insert(doc_id, staged.fields);
327            self.doc_terms.insert(doc_id, staged.terms);
328        }
329        Ok(())
330    }
331}