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::memory::OwnedMap;
18use uqa_core::{DocId, FieldName, IndexStats, Payload, PostingEntry, PostingList, TokenOccurrence};
19
20use crate::TokenTermKey;
21
22use crate::backend::{StorageBackendError, StorageBackendResult};
23use crate::block_max_index::BlockMaxScorer;
24use crate::clustered_postings::{MaterializedPostingCursor, PostingCursor, PostingScore};
25
26mod analysis;
27pub(crate) use analysis::analyze_index_field_with_scratch;
28mod batch;
29mod bindings;
30mod changes;
31mod contract;
32pub mod defaults;
33mod footprint;
34mod memory;
35mod metadata;
36mod read_cursor;
37mod retained;
38mod snapshot;
39mod source;
40mod source_rebuild;
41
42#[cfg(test)]
43mod tests;
44
45pub use analysis::{
46    analyze_index_field, analyze_index_field_budgeted, analyze_index_field_cancellable,
47    analyze_query_graph, analyze_query_graph_budgeted, analyze_query_terms,
48    analyze_query_terms_budgeted, AnalyzedField, IndexedFieldMetadata, RetainedAnalyzedField,
49};
50pub use bindings::{AnalyzerBindings, AnalyzerDefault, RetainedAnalyzerBindings};
51pub use changes::{visit_field_replacement, InvertedIndexChange, InvertedIndexChangeVisitor};
52pub use contract::{AnalyzerPhase, InvertedIndex};
53pub use metadata::IndexedFieldRevision;
54pub use retained::RetainedInvertedIndexBuilder;
55pub use source::{DocumentTextSource, TextIndexDocuments, TextIndexSource};
56pub use source_rebuild::{
57    FieldTotals, SourceRebuild, StagedCluster, StagedFieldRecord, StagedFields,
58};
59
60/// Linear term/position stores cannot install morphology without immutable graph revisions.
61pub fn validate_linear_analyzer(analyzer: &Analyzer) -> StorageBackendResult<()> {
62    if analyzer.uses_korean_stages() {
63        return Err(StorageBackendError::Other("Korean analyzers require immutable analyzer revisions and lossless token-graph storage".into()));
64    }
65    if analyzer.uses_japanese_stages() {
66        return Err(StorageBackendError::Other("Japanese analyzers require immutable analyzer revisions and lossless token-graph storage".into()));
67    }
68    Ok(())
69}
70
71/// A linear store must not accept a revision whose declared normalization policy it cannot preserve.
72pub fn validate_linear_revision(
73    revision: &uqa_analysis::CompiledAnalyzer,
74) -> StorageBackendResult<()> {
75    validate_linear_analyzer(&revision.descriptor().configuration()?)?;
76    if revision.descriptor().length_policy() != uqa_analysis::TokenLengthPolicy::EmittedTokens {
77        return Err(StorageBackendError::Other(
78            "overlap-discounted analyzer revisions require occurrence storage".into(),
79        ));
80    }
81    Ok(())
82}
83
84fn counter_error(context: &str) -> StorageBackendError {
85    StorageBackendError::Other(format!("inverted-index {context} overflow or corruption"))
86}
87
88fn usize_to_u64(value: usize, context: &str) -> StorageBackendResult<u64> {
89    u64::try_from(value).map_err(|_| counter_error(context))
90}
91
92fn checked_sum_u64(
93    values: impl IntoIterator<Item = u64>,
94    context: &str,
95) -> StorageBackendResult<u64> {
96    values.into_iter().try_fold(0_u64, |total, value| {
97        total
98            .checked_add(value)
99            .ok_or_else(|| counter_error(context))
100    })
101}
102
103#[derive(Debug)]
104pub struct MemoryInvertedIndex {
105    bindings: AnalyzerBindings,
106    /// Snapshots share immutable corpus state; validated writes detach it once.
107    state: Arc<MemoryIndexState>,
108    /// Controlled readers keep the shared generation's allowance after the source changes or closes.
109    state_memory: Option<Arc<uqa_core::memory::MemoryReservation>>,
110    read_control: Option<crate::read_control::StorageReadControl>,
111}
112
113/// Cloning retains the existing independently owned corpus maps; snapshot APIs share them until a write.
114impl Clone for MemoryInvertedIndex {
115    fn clone(&self) -> Self {
116        Self {
117            bindings: self.bindings.clone(),
118            state: Arc::new((*self.state).clone()),
119            state_memory: None,
120            read_control: None,
121        }
122    }
123}
124
125#[derive(Debug, Default)]
126struct MemoryIndexState {
127    /// `(field, term) -> doc_id -> entry (positions inside the doc)`
128    index: OwnedMap<PostingKey, OwnedMap<DocId, MemoryPosting>>,
129    /// Field metadata and reverse posting keys share one document owner.
130    documents: OwnedMap<DocId, MemoryDocument>,
131    /// Length and document totals share one field owner; BM25 statistics never scan the corpus.
132    field_counters: OwnedMap<FieldName, MemoryFieldCounters>,
133    doc_count: u64,
134    retention: footprint::RetainedPayload,
135}
136
137type PostingKey = (FieldName, TokenTermKey);
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140struct MemoryDocument {
141    /// Exact revision, normalization length and original source end state for each field.
142    fields: OwnedMap<FieldName, IndexedFieldMetadata>,
143    /// Ordered unique keys restrict removal to this document's postings.
144    terms: Vec<PostingKey>,
145}
146
147#[derive(Debug, Clone)]
148struct MemoryPosting {
149    doc_id: DocId,
150    occurrences: Vec<TokenOccurrence>,
151}
152
153impl MemoryPosting {
154    fn new(doc_id: DocId, occurrences: Vec<TokenOccurrence>) -> Self {
155        debug_assert!(occurrences
156            .windows(2)
157            .all(|pair| pair[0].position <= pair[1].position));
158        Self {
159            doc_id,
160            occurrences,
161        }
162    }
163
164    fn projection(&self) -> PostingEntry {
165        // Analysis emits nondecreasing positions. The legacy projection discards duplicate positions, while scoring and phrase matching retain every occurrence.
166        let mut positions: Vec<_> = self
167            .occurrences
168            .iter()
169            .map(|occurrence| occurrence.position)
170            .collect();
171        positions.dedup();
172        PostingEntry::new(
173            self.doc_id,
174            Payload {
175                positions,
176                score: 0.0,
177                fields: BTreeMap::new(),
178            },
179        )
180    }
181}
182
183struct StagedMemoryDocument {
184    fields: OwnedMap<FieldName, IndexedFieldMetadata>,
185    terms: Vec<PostingKey>,
186    postings: Vec<(PostingKey, MemoryPosting)>,
187}
188
189struct MemoryReplacementPlan {
190    old_terms: Vec<PostingKey>,
191    next_doc_count: u64,
192    field_counters: OwnedMap<FieldName, MemoryFieldCounters>,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196struct MemoryFieldCounters {
197    total: u64,
198    docs: u64,
199}
200
201impl MemoryInvertedIndex {
202    pub fn new(analyzer: Analyzer) -> Self {
203        Self::with_bindings(AnalyzerBindings::new(analyzer))
204    }
205
206    fn with_bindings(bindings: AnalyzerBindings) -> Self {
207        Self {
208            bindings,
209            state: Arc::default(),
210            state_memory: None,
211            read_control: None,
212        }
213    }
214
215    fn shared_snapshot(&self) -> Self {
216        Self {
217            bindings: self.bindings.clone(),
218            state: Arc::clone(&self.state),
219            state_memory: self.state_memory.clone(),
220            read_control: self.read_control.clone(),
221        }
222    }
223
224    fn check_retained_read(&self) -> StorageBackendResult<()> {
225        self.read_control
226            .as_ref()
227            .map_or(Ok(()), crate::read_control::StorageReadControl::check)
228    }
229
230    fn stage_document(
231        &self,
232        doc_id: DocId,
233        fields: BTreeMap<FieldName, String>,
234    ) -> StorageBackendResult<StagedMemoryDocument> {
235        self.stage_document_inner(doc_id, fields, None)
236    }
237
238    fn stage_document_inner(
239        &self,
240        doc_id: DocId,
241        fields: BTreeMap<FieldName, String>,
242        cancellation: Option<&uqa_core::CancellationToken>,
243    ) -> StorageBackendResult<StagedMemoryDocument> {
244        let mut metadata = OwnedMap::new();
245        let mut terms = Vec::new();
246        let mut postings = Vec::new();
247        for (field, text) in fields {
248            if let Some(cancellation) = cancellation {
249                cancellation.check()?;
250            }
251            let revision = self.bindings.index_revision(&field)?;
252            let analyzed = match cancellation {
253                Some(cancellation) => {
254                    analyze_index_field_cancellable(&revision, &text, cancellation)?
255                }
256                None => analyze_index_field(&revision, &text)?,
257            };
258            metadata.insert(
259                field.clone(),
260                IndexedFieldMetadata::new(&revision, &analyzed),
261            );
262            terms.reserve(analyzed.terms.len());
263            for (term, occurrences) in analyzed.terms {
264                if let Some(cancellation) = cancellation {
265                    cancellation.check()?;
266                }
267                let key = (field.clone(), term);
268                terms.push(key.clone());
269                postings.push((key, MemoryPosting::new(doc_id, occurrences)));
270            }
271        }
272        Ok(StagedMemoryDocument {
273            fields: metadata,
274            terms,
275            postings,
276        })
277    }
278}
279
280impl MemoryIndexState {
281    fn document(&self, doc_id: DocId) -> StorageBackendResult<Option<&MemoryDocument>> {
282        let document = self.documents.get(&doc_id);
283        if document.is_some_and(|document| document.fields.is_empty()) {
284            return Err(StorageBackendError::Other(format!(
285                "inverted-index document {doc_id} has inconsistent reverse-index state"
286            )));
287        }
288        Ok(document)
289    }
290
291    fn plan_replacement(
292        &self,
293        doc_id: DocId,
294        new_fields: &OwnedMap<FieldName, IndexedFieldMetadata>,
295    ) -> StorageBackendResult<MemoryReplacementPlan> {
296        self.plan_replacement_with_names(doc_id, new_fields, |field| Ok(field.clone()))
297    }
298
299    fn plan_replacement_with_names(
300        &self,
301        doc_id: DocId,
302        new_fields: &OwnedMap<FieldName, IndexedFieldMetadata>,
303        mut copy_name: impl FnMut(&FieldName) -> StorageBackendResult<FieldName>,
304    ) -> StorageBackendResult<MemoryReplacementPlan> {
305        let previous = self.document(doc_id)?;
306        let old_terms = previous
307            .map(|document| document.terms.clone())
308            .unwrap_or_default();
309        let empty_fields = OwnedMap::new();
310        let old_fields = previous.map_or(&empty_fields, |document| &document.fields);
311        let next_doc_count = self
312            .doc_count
313            .checked_sub(u64::from(previous.is_some()))
314            .ok_or_else(|| counter_error("document count"))?
315            .checked_add(u64::from(!new_fields.is_empty()))
316            .ok_or_else(|| counter_error("document count"))?;
317        for key in &old_terms {
318            if !self
319                .index
320                .get(key)
321                .is_some_and(|postings| postings.contains_key(&doc_id))
322            {
323                return Err(StorageBackendError::Other(format!(
324                    "inverted-index document {doc_id} references a missing posting"
325                )));
326            }
327        }
328
329        // Merge the two already ordered field views without allocating a temporary set.
330        let mut old_keys = old_fields.keys().peekable();
331        let mut new_keys = new_fields.keys().peekable();
332        let affected_fields = std::iter::from_fn(|| match (old_keys.peek(), new_keys.peek()) {
333            (Some(old), Some(new)) => match old.cmp(new) {
334                std::cmp::Ordering::Less => old_keys.next(),
335                std::cmp::Ordering::Greater => new_keys.next(),
336                std::cmp::Ordering::Equal => {
337                    old_keys.next();
338                    new_keys.next()
339                }
340            },
341            (Some(_), None) => old_keys.next(),
342            (None, Some(_)) => new_keys.next(),
343            (None, None) => None,
344        });
345        let mut field_counters = OwnedMap::new();
346        for field in affected_fields {
347            let old_length = old_fields.get(field).map_or(0, |metadata| metadata.length);
348            let new_length = new_fields.get(field).map_or(0, |metadata| metadata.length);
349            let total = self
350                .field_counters
351                .get(field)
352                .map_or(0, |counters| counters.total)
353                .checked_sub(old_length)
354                .ok_or_else(|| counter_error("total field length"))?
355                .checked_add(new_length)
356                .ok_or_else(|| counter_error("total field length"))?;
357            let field_docs = self
358                .field_counters
359                .get(field)
360                .map_or(0, |counters| counters.docs)
361                .checked_sub(u64::from(old_fields.contains_key(field)))
362                .ok_or_else(|| counter_error("field document count"))?
363                .checked_add(u64::from(new_fields.contains_key(field)))
364                .ok_or_else(|| counter_error("field document count"))?;
365            field_counters.insert(
366                copy_name(field)?,
367                MemoryFieldCounters {
368                    total,
369                    docs: field_docs,
370                },
371            );
372        }
373        Ok(MemoryReplacementPlan {
374            old_terms,
375            next_doc_count,
376            field_counters,
377        })
378    }
379
380    fn apply_replacement(
381        &mut self,
382        doc_id: DocId,
383        staged: StagedMemoryDocument,
384        plan: MemoryReplacementPlan,
385    ) -> StorageBackendResult<()> {
386        for key in plan.old_terms {
387            self.remove_posting(doc_id, &key)?;
388        }
389        self.remove_document_metadata(doc_id);
390        for (field, counters) in plan.field_counters {
391            footprint::set_counter(
392                &mut self.field_counters,
393                field,
394                (counters.docs != 0).then_some(counters),
395                &mut self.retention,
396            );
397        }
398        for (key, entry) in staged.postings {
399            self.insert_posting(doc_id, key, entry);
400        }
401        self.doc_count = plan.next_doc_count;
402        if !staged.fields.is_empty() {
403            self.insert_document_metadata(doc_id, staged.fields, staged.terms);
404        }
405        Ok(())
406    }
407}