1use 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
45pub 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
56pub 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 state: Arc<MemoryIndexState>,
93}
94
95impl 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 index: BTreeMap<PostingKey, BTreeMap<DocId, MemoryPosting>>,
109 doc_terms: BTreeMap<DocId, BTreeSet<PostingKey>>,
112 doc_fields: BTreeMap<DocId, BTreeMap<FieldName, IndexedFieldMetadata>>,
114 total_length: BTreeMap<FieldName, u64>,
116 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}