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