Skip to main content

uqa_storage/inverted_index/
contract.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7use super::IndexedFieldMetadata;
8use super::{
9    counter_error, Analyzer, Arc, BTreeMap, BlockMaxScorer, DocId, FieldName, IndexStats,
10    PostingEntry, PostingList, StorageBackendError, StorageBackendResult,
11};
12use crate::clustered_postings::BudgetedPostingReadCursor;
13use crate::clustered_postings::{
14    MaterializedPostingCursor, OccurrencePosting, PostingCursor, PostingScore,
15};
16use crate::read_control::StorageReadControl;
17use crate::TokenTermKey;
18use uqa_core::memory::Budgeted;
19use uqa_core::TokenOccurrence;
20
21/// Which side of the index/search pipeline a field analyzer applies to.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum AnalyzerPhase {
25    /// Run only when *adding* documents.
26    Index,
27    /// Run only when *querying* documents (e.g. through `TermOperator`).
28    Search,
29    /// Run on both phases (the default).
30    Both,
31}
32
33impl AnalyzerPhase {
34    pub fn parse(s: &str) -> Result<Self, String> {
35        match s {
36            "index" => Ok(AnalyzerPhase::Index),
37            "search" | "query" => Ok(AnalyzerPhase::Search),
38            "both" => Ok(AnalyzerPhase::Both),
39            _ => Err(format!("phase must be 'index'|'search'|'both', got `{s}`")),
40        }
41    }
42}
43
44impl std::str::FromStr for AnalyzerPhase {
45    type Err = String;
46
47    fn from_str(s: &str) -> Result<Self, Self::Err> {
48        Self::parse(s)
49    }
50}
51
52pub trait InvertedIndex: Send + Sync {
53    /// Whether persisted positional data must be rebuilt from original sources before this index can be read or mutated. The owning engine performs this after restoring exact analyzer revisions, in the same initial-open transaction.
54    fn source_rebuild_required(&self) -> StorageBackendResult<bool> {
55        Ok(false)
56    }
57
58    fn analyzer(&self) -> &Analyzer;
59
60    fn add_document(
61        &mut self,
62        doc_id: DocId,
63        fields: BTreeMap<FieldName, String>,
64    ) -> StorageBackendResult<()>;
65
66    fn try_add_document(
67        &mut self,
68        doc_id: DocId,
69        fields: BTreeMap<FieldName, String>,
70    ) -> StorageBackendResult<()> {
71        self.add_document(doc_id, fields)
72    }
73
74    /// Add or replace several documents in input order. The default preserves the point-mutation contract for custom backends; transactional persistent backends can override this to make the call atomic and coalesce writes that share physical posting clusters.
75    fn try_add_documents(
76        &mut self,
77        documents: Vec<(DocId, BTreeMap<FieldName, String>)>,
78    ) -> StorageBackendResult<()> {
79        for (doc_id, fields) in documents {
80            self.try_add_document(doc_id, fields)?;
81        }
82        Ok(())
83    }
84
85    fn remove_document(&mut self, doc_id: DocId) -> StorageBackendResult<()>;
86
87    fn try_remove_document(&mut self, doc_id: DocId) -> StorageBackendResult<()> {
88        self.remove_document(doc_id)
89    }
90
91    fn clear(&mut self) -> StorageBackendResult<()>;
92
93    fn try_clear(&mut self) -> StorageBackendResult<()> {
94        self.clear()
95    }
96
97    fn try_rebuild_documents(
98        &mut self,
99        documents: Vec<(DocId, BTreeMap<FieldName, String>)>,
100    ) -> StorageBackendResult<()> {
101        self.try_clear()?;
102        for (doc_id, fields) in documents {
103            if !fields.is_empty() {
104                self.try_add_document(doc_id, fields)?;
105            }
106        }
107        Ok(())
108    }
109
110    fn get_posting_list(&self, field: &str, term: &str) -> StorageBackendResult<PostingList>;
111
112    /// Unique-position compatibility projection for an exact term key. Legacy providers accept scalar keys and reject unpaired UTF-16 explicitly.
113    fn get_posting_list_key(
114        &self,
115        field: &str,
116        term: &TokenTermKey,
117    ) -> StorageBackendResult<PostingList> {
118        self.get_posting_list(field, &term.to_term().into_string()?)
119    }
120
121    /// Score cursor with exact term identity and occurrence frequency independent of unique positions.
122    fn posting_cursor_key(
123        &self,
124        field: &str,
125        term: &TokenTermKey,
126    ) -> StorageBackendResult<Box<dyn PostingCursor>> {
127        self.posting_cursor(field, &term.to_term().into_string()?)
128    }
129
130    /// Traverse candidates while retaining this index read. Providers with borrowed posting maps can avoid copying the entire term support; owned persistent cursors keep their incremental reads.
131    fn posting_read_cursor_key<'a>(
132        &'a self,
133        field: &'a str,
134        term: &TokenTermKey,
135    ) -> StorageBackendResult<Box<dyn crate::clustered_postings::PostingReadCursor + 'a>> {
136        Ok(Box::new(crate::clustered_postings::OwnedPostingReadCursor(
137            self.posting_cursor_key(field, term)?,
138        )))
139    }
140
141    /// Open a cursor that owns every query allocation under the supplied allowance. Providers must implement this capability without an unbounded materialization fallback.
142    fn posting_read_cursor_key_budgeted<'a>(
143        &'a self,
144        field: &'a str,
145        term: &'a TokenTermKey,
146        control: &StorageReadControl,
147    ) -> StorageBackendResult<BudgetedPostingReadCursor<'a>> {
148        crate::clustered_postings::open_controlled_cursor(self, field, term, control)
149    }
150
151    /// Visit encoded score clusters in ascending order under the retained provider read. Temporary payloads must be reserved before fetching; callbacks must not reenter the provider.
152    fn visit_score_clusters(
153        &self,
154        _field: &str,
155        _term: &TokenTermKey,
156        _after: Option<u64>,
157        _limit: usize,
158        control: &StorageReadControl,
159        _visit: &mut crate::clustered_postings::ScoreClusterVisitor<'_>,
160    ) -> StorageBackendResult<()> {
161        control.check()?;
162        Err(StorageBackendError::Other(
163            "controlled score cluster reads are not supported by this backend".into(),
164        ))
165    }
166
167    /// Decode one document's exact occurrences with provider-owned input and output reservations and cancellation checks.
168    fn get_occurrences_budgeted(
169        &self,
170        _doc_id: DocId,
171        _field: &str,
172        _term: &TokenTermKey,
173        control: &StorageReadControl,
174    ) -> StorageBackendResult<Budgeted<Vec<TokenOccurrence>>> {
175        control.check()?;
176        Err(StorageBackendError::Other(
177            "controlled occurrence reads are not supported by this backend".into(),
178        ))
179    }
180
181    /// Complete graph edges in document order, preserving occurrence multiplicity and original source coordinates. Legacy positions cannot implement this contract without a source rebuild.
182    fn get_occurrence_postings(
183        &self,
184        _field: &str,
185        _term: &TokenTermKey,
186    ) -> StorageBackendResult<Vec<OccurrencePosting>> {
187        Err(StorageBackendError::Other(
188            "lossless occurrence storage is not supported by this backend".into(),
189        ))
190    }
191
192    /// Exact occurrences for one document and term; an absent document or term has no occurrences.
193    fn get_occurrences(
194        &self,
195        doc_id: DocId,
196        field: &str,
197        term: &TokenTermKey,
198    ) -> StorageBackendResult<Vec<TokenOccurrence>> {
199        Ok(self
200            .get_occurrence_postings(field, term)?
201            .into_iter()
202            .find(|posting| posting.doc_id == doc_id)
203            .map_or_else(Vec::new, |posting| posting.occurrences))
204    }
205
206    /// Original stream-end state and revision metadata published with a document field, including fields that emitted no tokens.
207    fn indexed_field_metadata(
208        &self,
209        _doc_id: DocId,
210        _field: &str,
211    ) -> StorageBackendResult<Option<IndexedFieldMetadata>> {
212        Err(StorageBackendError::Other(
213            "indexed field analysis metadata is not supported by this backend".into(),
214        ))
215    }
216
217    fn doc_freq_key(&self, field: &str, term: &TokenTermKey) -> StorageBackendResult<u64> {
218        self.doc_freq(field, &term.to_term().into_string()?)
219    }
220
221    fn get_term_freq_key(
222        &self,
223        doc_id: DocId,
224        field: &str,
225        term: &TokenTermKey,
226    ) -> StorageBackendResult<u64> {
227        self.get_term_freq(doc_id, field, &term.to_term().into_string()?)
228    }
229
230    /// Sorted canonical term keys, including unpaired units. String-only vocabulary APIs must return an error if projection would lose identity.
231    fn vocabulary_keys(&self, field: &str) -> StorageBackendResult<Vec<TokenTermKey>> {
232        Ok(self
233            .vocabulary_terms(field)?
234            .iter()
235            .map(|term| TokenTermKey::from_text(term))
236            .collect())
237    }
238
239    fn get_posting_lists_bulk(
240        &self,
241        field: &str,
242        terms: &[String],
243    ) -> StorageBackendResult<Vec<PostingList>> {
244        terms
245            .iter()
246            .map(|term| self.get_posting_list(field, term))
247            .collect()
248    }
249
250    /// Open a doc-id ordered score cursor for one term.
251    ///
252    /// The cursor carries term frequency and document length directly so
253    /// ranking does not need positional payloads or per-document length
254    /// lookups. Persistent backends override this with lazy clustered
255    /// cursors; the default preserves compatibility for custom backends.
256    fn posting_cursor(
257        &self,
258        field: &str,
259        term: &str,
260    ) -> StorageBackendResult<Box<dyn PostingCursor>> {
261        let posting_list = self.get_posting_list(field, term)?;
262        let mut entries = Vec::with_capacity(posting_list.len());
263        for posting in posting_list {
264            let term_freq = self.get_term_freq(posting.doc_id, field, term)?;
265            entries.push(PostingScore {
266                doc_id: posting.doc_id,
267                term_freq,
268                doc_length: self.get_doc_length(posting.doc_id, field)?,
269            });
270        }
271        Ok(Box::new(MaterializedPostingCursor::new(entries)?))
272    }
273
274    fn posting_cursors_bulk(
275        &self,
276        field: &str,
277        terms: &[String],
278    ) -> StorageBackendResult<Vec<Box<dyn PostingCursor>>> {
279        terms
280            .iter()
281            .map(|term| self.posting_cursor(field, term))
282            .collect()
283    }
284
285    /// Open exact-key cursors in input order, retaining repeated query terms. Scalar custom backends retain their optimized bulk implementation.
286    fn posting_cursors_keys_bulk(
287        &self,
288        field: &str,
289        terms: &[TokenTermKey],
290    ) -> StorageBackendResult<Vec<Box<dyn PostingCursor>>> {
291        if let Some(scalar) = terms
292            .iter()
293            .map(|key| key.as_str().map(str::to_owned))
294            .collect::<Option<Vec<_>>>()
295        {
296            return self.posting_cursors_bulk(field, &scalar);
297        }
298        terms
299            .iter()
300            .map(|term| self.posting_cursor_key(field, term))
301            .collect()
302    }
303
304    /// Read exact-key support without projecting UTF-16 term identity.
305    fn get_posting_lists_keys_bulk(
306        &self,
307        field: &str,
308        terms: &[TokenTermKey],
309    ) -> StorageBackendResult<Vec<PostingList>> {
310        if let Some(scalar) = terms
311            .iter()
312            .map(|key| key.as_str().map(str::to_owned))
313            .collect::<Option<Vec<_>>>()
314        {
315            return self.get_posting_lists_bulk(field, &scalar);
316        }
317        terms
318            .iter()
319            .map(|term| self.get_posting_list_key(field, term))
320            .collect()
321    }
322
323    /// Load exact-key scorer-versioned bounds. Custom scalar providers expose no raw-key materialization by default.
324    fn persisted_block_max_scores_keys_bulk(
325        &self,
326        field: &str,
327        terms: &[TokenTermKey],
328        scorer_fingerprint: &str,
329    ) -> StorageBackendResult<Vec<Option<Vec<f64>>>> {
330        if let Some(scalar) = terms
331            .iter()
332            .map(|key| key.as_str().map(str::to_owned))
333            .collect::<Option<Vec<_>>>()
334        {
335            return self.persisted_block_max_scores_bulk(field, &scalar, scorer_fingerprint);
336        }
337        terms
338            .iter()
339            .map(|key| match key.as_str() {
340                Some(term) => self.persisted_block_max_scores(field, term, scorer_fingerprint),
341                None => Ok(None),
342            })
343            .collect()
344    }
345
346    /// Exact-key scoring inputs aligned with both the document and query-term arrays, including repetitions.
347    fn get_scoring_inputs_keys_bulk(
348        &self,
349        doc_ids: &[DocId],
350        field: &str,
351        terms: &[TokenTermKey],
352    ) -> StorageBackendResult<Vec<(u64, Vec<u64>)>> {
353        if let Some(scalar) = terms
354            .iter()
355            .map(|key| key.as_str().map(str::to_owned))
356            .collect::<Option<Vec<_>>>()
357        {
358            return self.get_scoring_inputs_bulk(doc_ids, field, &scalar);
359        }
360        doc_ids
361            .iter()
362            .map(|id| {
363                Ok((
364                    self.get_doc_length(*id, field)?,
365                    terms
366                        .iter()
367                        .map(|key| self.get_term_freq_key(*id, field, key))
368                        .collect::<StorageBackendResult<_>>()?,
369                ))
370            })
371            .collect()
372    }
373
374    /// Persist scorer-specific block maxima for every term in `field`.
375    ///
376    /// Backends that do not provide durable auxiliary indexes return `false`.
377    /// The fingerprint must include every scorer and corpus statistic that can
378    /// affect a term contribution; reads only expose rows with an exact match.
379    fn rebuild_persisted_block_max(
380        &mut self,
381        _field: &str,
382        _scorer: &dyn BlockMaxScorer,
383        _scorer_fingerprint: &str,
384    ) -> StorageBackendResult<bool> {
385        Ok(false)
386    }
387
388    /// Load scorer-versioned block maxima for one posting list. `None` means
389    /// the backend has no complete, valid materialization for this scorer.
390    fn persisted_block_max_scores(
391        &self,
392        _field: &str,
393        _term: &str,
394        _scorer_fingerprint: &str,
395    ) -> StorageBackendResult<Option<Vec<f64>>> {
396        Ok(None)
397    }
398
399    /// Load scorer-versioned block maxima for several terms while preserving input order; persistent backends override this to avoid one storage round trip per term.
400    fn persisted_block_max_scores_bulk(
401        &self,
402        field: &str,
403        terms: &[String],
404        scorer_fingerprint: &str,
405    ) -> StorageBackendResult<Vec<Option<Vec<f64>>>> {
406        terms
407            .iter()
408            .map(|term| self.persisted_block_max_scores(field, term, scorer_fingerprint))
409            .collect()
410    }
411
412    /// Visit every posting entry for `(field, term)` in ascending
413    /// doc-id order without handing out an owned list.
414    ///
415    /// [`InvertedIndex::get_posting_list`] deep-copies each entry's
416    /// payload (positions vector included), which costs one heap
417    /// allocation per matching document. Read-only scoring walks use
418    /// this instead; backends whose postings already live in memory
419    /// override it to iterate in place.
420    fn for_each_posting(
421        &self,
422        field: &str,
423        term: &str,
424        visit: &mut dyn FnMut(&PostingEntry),
425    ) -> StorageBackendResult<()> {
426        for entry in &self.get_posting_list(field, term)? {
427            visit(entry);
428        }
429        Ok(())
430    }
431
432    /// Visit `(doc_id, term_frequency)` pairs without requiring callers to
433    /// materialize or decode payload details they do not use. The default
434    /// uses posting support and the authoritative frequency accessor;
435    /// persistent backends can stream compact frequency projections.
436    fn for_each_term_freq(
437        &self,
438        field: &str,
439        term: &str,
440        visit: &mut dyn FnMut(DocId, u64),
441    ) -> StorageBackendResult<()> {
442        for entry in &self.get_posting_list(field, term)? {
443            visit(entry.doc_id, self.get_term_freq(entry.doc_id, field, term)?);
444        }
445        Ok(())
446    }
447
448    fn doc_freq(&self, field: &str, term: &str) -> StorageBackendResult<u64>;
449
450    fn get_doc_length(&self, doc_id: DocId, field: &str) -> StorageBackendResult<u64>;
451
452    fn get_term_freq(&self, doc_id: DocId, field: &str, term: &str) -> StorageBackendResult<u64>;
453
454    fn doc_count(&self) -> StorageBackendResult<u64>;
455
456    fn total_field_length(&self, field: &str) -> StorageBackendResult<u64>;
457
458    /// Number of documents that have indexed content for `field`.
459    fn field_doc_count(&self, field: &str) -> StorageBackendResult<u64> {
460        self.doc_length_count(Some(field))
461    }
462
463    /// Field-specific statistics for BM25 scoring.
464    ///
465    /// BM25 length normalization and IDF collection size are defined for
466    /// one field. Reusing table-wide totals mixes unrelated field lengths
467    /// and produces scores that cannot match a field-scoped BM25 scorer.
468    fn field_stats(&self, field: &str) -> StorageBackendResult<IndexStats> {
469        let mut stats = self.stats()?;
470        let field_docs = self.field_doc_count(field)?;
471        stats.total_docs = field_docs;
472        stats.avg_doc_length = if field_docs > 0 {
473            self.total_field_length(field)? as f64 / field_docs as f64
474        } else {
475            0.0
476        };
477        Ok(stats)
478    }
479
480    /// [`InvertedIndex::field_stats`] without the vocabulary-wide
481    /// document-frequency map.
482    ///
483    /// Query execution that already knows its terms' document
484    /// frequencies (it read them off the posting lists) only needs the
485    /// field's document count and average length; copying the whole
486    /// term dictionary per query is O(vocabulary) for nothing.
487    fn field_stats_scalar(&self, field: &str) -> StorageBackendResult<IndexStats> {
488        let mut stats = IndexStats::default();
489        let field_docs = self.field_doc_count(field)?;
490        stats.total_docs = field_docs;
491        stats.avg_doc_length = if field_docs > 0 {
492            self.total_field_length(field)? as f64 / field_docs as f64
493        } else {
494            0.0
495        };
496        Ok(stats)
497    }
498
499    /// Read only field scoring scalars with producer-owned temporary reservations.
500    fn field_stats_scalar_budgeted(
501        &self,
502        _field: &str,
503        control: &StorageReadControl,
504    ) -> StorageBackendResult<IndexStats> {
505        control.check()?;
506        Err(StorageBackendError::Other(
507            "controlled field statistics are not supported by this backend".into(),
508        ))
509    }
510
511    /// Sorted unique indexed terms for `field`.
512    ///
513    /// Backends implement this from their term dictionary rather than by
514    /// re-analyzing stored documents. This is the source used by Bayesian
515    /// calibration reservoir sampling.
516    fn vocabulary_terms(&self, _field: &str) -> StorageBackendResult<Vec<String>> {
517        Ok(Vec::new())
518    }
519
520    /// Fully-populated [`IndexStats`] snapshot for the cost model and
521    /// scoring layer. Implementations may cache this between mutations.
522    fn stats(&self) -> StorageBackendResult<IndexStats>;
523
524    /// Number of posting rows. With `field = Some(..)`, limits the count
525    /// to one indexed field.
526    fn posting_count(&self, _field: Option<&str>) -> StorageBackendResult<u64> {
527        Ok(0)
528    }
529
530    /// Number of `(doc_id, field)` length rows. With `field = Some(..)`,
531    /// this is the number of documents indexed for that field.
532    fn doc_length_count(&self, _field: Option<&str>) -> StorageBackendResult<u64> {
533        Ok(0)
534    }
535
536    /// Number of distinct indexed terms. With `field = Some(..)`, limits
537    /// the count to one indexed field.
538    fn term_count(&self, _field: Option<&str>) -> StorageBackendResult<u64> {
539        Ok(0)
540    }
541
542    /// Read-only handle suitable for an `ExecutionContext`.
543    fn snapshot(&self) -> StorageBackendResult<Arc<dyn InvertedIndex>>;
544
545    /// Independent writable copy used to restore an in-memory engine
546    /// transaction without reconstructing analyzer state from documents.
547    fn writable_snapshot(&self) -> StorageBackendResult<Box<dyn InvertedIndex>> {
548        Err(StorageBackendError::Other(
549            "writable inverted-index snapshots are not supported by this backend".into(),
550        ))
551    }
552
553    // -- Extended inverted-index surface ---
554
555    /// Names of every field with at least one indexed document.
556    /// Default implementation walks the [`IndexStats`] snapshot's
557    /// total-length map. Backends with a richer schema can override.
558    fn field_names(&self) -> StorageBackendResult<Vec<FieldName>> {
559        Ok(Vec::new())
560    }
561
562    /// Posting list for `term` across every indexed field, unioned
563    /// together. Default implementation sums per-field posting lists
564    /// via [`PostingList::merge_union`].
565    fn get_posting_list_any_field(&self, term: &str) -> StorageBackendResult<PostingList> {
566        let mut result = PostingList::new();
567        for field in self.field_names()? {
568            let pl = self.get_posting_list(&field, term)?;
569            result = result.merge_union(&pl);
570        }
571        Ok(result)
572    }
573
574    /// Document frequency of `term` across every indexed field.
575    fn doc_freq_any_field(&self, term: &str) -> StorageBackendResult<u64> {
576        let mut total = 0_u64;
577        for field in self.field_names()? {
578            total = total
579                .checked_add(self.doc_freq(&field, term)?)
580                .ok_or_else(|| counter_error("document frequency"))?;
581        }
582        Ok(total)
583    }
584
585    /// Sum of all per-field token lengths for a single doc.
586    fn get_total_doc_length(&self, doc_id: DocId) -> StorageBackendResult<u64> {
587        let mut total = 0_u64;
588        for field in self.field_names()? {
589            total = total
590                .checked_add(self.get_doc_length(doc_id, &field)?)
591                .ok_or_else(|| counter_error("document length"))?;
592        }
593        Ok(total)
594    }
595
596    /// Bulk doc-length lookup. Default falls back to per-id calls.
597    fn get_doc_lengths_bulk(
598        &self,
599        doc_ids: &[DocId],
600        field: &str,
601    ) -> StorageBackendResult<BTreeMap<DocId, u64>> {
602        let mut out = BTreeMap::new();
603        for doc_id in doc_ids {
604            out.insert(*doc_id, self.get_doc_length(*doc_id, field)?);
605        }
606        Ok(out)
607    }
608
609    /// Bulk term-frequency lookup. Default falls back to per-id calls.
610    fn get_term_freqs_bulk(
611        &self,
612        doc_ids: &[DocId],
613        field: &str,
614        term: &str,
615    ) -> StorageBackendResult<BTreeMap<DocId, u64>> {
616        let mut out = BTreeMap::new();
617        for doc_id in doc_ids {
618            out.insert(*doc_id, self.get_term_freq(*doc_id, field, term)?);
619        }
620        Ok(out)
621    }
622
623    /// Fetch the document length and one term frequency per query term for
624    /// every requested document. Results stay aligned with `doc_ids`.
625    /// Persistent backends override this to collapse the scoring loop's
626    /// per-document point reads into a small number of set-oriented queries.
627    fn get_scoring_inputs_bulk(
628        &self,
629        doc_ids: &[DocId],
630        field: &str,
631        terms: &[String],
632    ) -> StorageBackendResult<Vec<(u64, Vec<u64>)>> {
633        let mut out = Vec::with_capacity(doc_ids.len());
634        for doc_id in doc_ids {
635            let mut term_freqs = Vec::with_capacity(terms.len());
636            for term in terms {
637                term_freqs.push(self.get_term_freq(*doc_id, field, term)?);
638            }
639            out.push((self.get_doc_length(*doc_id, field)?, term_freqs));
640        }
641        Ok(out)
642    }
643
644    /// Total term frequency for a doc summed across every indexed
645    /// field.
646    fn get_total_term_freq(&self, doc_id: DocId, term: &str) -> StorageBackendResult<u64> {
647        let mut total = 0_u64;
648        for field in self.field_names()? {
649            total = total
650                .checked_add(self.get_term_freq(doc_id, &field, term)?)
651                .ok_or_else(|| counter_error("term frequency"))?;
652        }
653        Ok(total)
654    }
655
656    /// Bind an analyzer to a single field for the given phase.
657    /// `Both` writes to both the index-side and search-side maps; the
658    /// default impl errors so backends that don't support per-field
659    /// analyzers fail loud rather than silently dropping the request.
660    fn set_field_analyzer(
661        &mut self,
662        _field: &str,
663        _analyzer: Analyzer,
664        _phase: AnalyzerPhase,
665    ) -> Result<(), String> {
666        Err("set_field_analyzer not supported by this InvertedIndex backend".into())
667    }
668
669    /// Remove every per-field analyzer override for `field`.  This is the
670    /// inverse of `set_field_analyzer(..., Both)` and is required when the
671    /// final logical FTS index for a field is dropped.  The default errors so
672    /// a backend cannot silently retain stale analysis behavior.
673    fn remove_field_analyzers(&mut self, _field: &str) -> Result<(), String> {
674        Err("remove_field_analyzers not supported by this InvertedIndex backend".into())
675    }
676
677    /// Index-time analyzer for `field`; falls back to
678    /// [`InvertedIndex::analyzer`] when no override is set.
679    fn get_field_analyzer(&self, _field: &str) -> Analyzer {
680        self.analyzer().clone()
681    }
682
683    /// Compatibility configuration for search. Built-in providers return their independent retained search revision's inputs; this default preserves the index fallback for custom legacy providers. Use `search_analyzer_revision` for execution with exact resource ownership.
684    fn get_search_analyzer(&self, field: &str) -> Analyzer {
685        self.get_field_analyzer(field)
686    }
687
688    /// Retain the exact executable index revision. Built-in providers resolve their default once and keep field revisions immutable.
689    fn index_analyzer_revision(
690        &self,
691        field: &str,
692    ) -> StorageBackendResult<Arc<uqa_analysis::CompiledAnalyzer>> {
693        Ok(self.get_field_analyzer(field).compile()?)
694    }
695
696    /// Retain the exact executable search revision independently of subsequent index assignments.
697    fn search_analyzer_revision(
698        &self,
699        field: &str,
700    ) -> StorageBackendResult<Arc<uqa_analysis::CompiledAnalyzer>> {
701        Ok(self.get_search_analyzer(field).compile()?)
702    }
703
704    /// Install a validated revision without reopening its resources. This does not rebuild existing documents; graph providers reject a different index revision on a populated field and require `rebuild_with_analyzer_revision` instead.
705    fn set_field_analyzer_revision(
706        &mut self,
707        _field: &str,
708        _revision: Arc<uqa_analysis::CompiledAnalyzer>,
709        _phase: AnalyzerPhase,
710    ) -> Result<(), String> {
711        Err("immutable analyzer revisions are not supported by this backend".into())
712    }
713
714    /// Install a complete retained pair atomically. Failure changes neither side; this does not rebuild existing postings.
715    fn set_field_analyzer_revisions(
716        &mut self,
717        _field: &str,
718        _index: Arc<uqa_analysis::CompiledAnalyzer>,
719        _search: Arc<uqa_analysis::CompiledAnalyzer>,
720    ) -> Result<(), String> {
721        Err("atomic analyzer revision pairs are not supported by this backend".into())
722    }
723
724    /// Replace the complete indexed document set and selected analyzer sides together. Failure retains the previous postings and bindings; providers must implement their own atomic publication.
725    fn rebuild_with_analyzer_revision(
726        &mut self,
727        _field: &str,
728        _revision: Arc<uqa_analysis::CompiledAnalyzer>,
729        _phase: AnalyzerPhase,
730        _documents: Vec<(DocId, BTreeMap<FieldName, String>)>,
731    ) -> StorageBackendResult<()> {
732        Err(StorageBackendError::Other(
733            "atomic analyzer revision rebuild is not supported by this backend".into(),
734        ))
735    }
736
737    /// Rebuild under the caller's cancellation token. Cancellation must retain the complete previous index; custom providers must implement atomic staging and cancellation.
738    fn try_rebuild_documents_cancellable(
739        &mut self,
740        _documents: Vec<(DocId, BTreeMap<FieldName, String>)>,
741        cancellation: &uqa_core::CancellationToken,
742    ) -> StorageBackendResult<()> {
743        cancellation.check()?;
744        Err(StorageBackendError::Other(
745            "cancellable atomic index rebuild is not supported by this backend".into(),
746        ))
747    }
748
749    /// Replace postings and selected analyzer revisions together, retaining both on cancellation before publication.
750    fn rebuild_with_analyzer_revision_cancellable(
751        &mut self,
752        _field: &str,
753        _revision: Arc<uqa_analysis::CompiledAnalyzer>,
754        _phase: AnalyzerPhase,
755        _documents: Vec<(DocId, BTreeMap<FieldName, String>)>,
756        cancellation: &uqa_core::CancellationToken,
757    ) -> StorageBackendResult<()> {
758        cancellation.check()?;
759        Err(StorageBackendError::Other(
760            "cancellable atomic analyzer revision rebuild is not supported by this backend".into(),
761        ))
762    }
763}