Skip to main content

zeppelin_embed/fts/
index.rs

1//! The lexical index: per-segment postings and store-wide statistics.
2//!
3//! # Global statistics are the whole point
4//!
5//! [`LexicalIndex`](crate::fts::index::LexicalIndex) owns every segment, and
6//! every statistic the scorer needs
7//! — `N`, `avgdl`, and per-term `df` — is derived across all of them. A
8//! segment cannot answer a scoring question by itself and is not asked to.
9//!
10//! Segment-local IDF is a documented real-world failure (Milvus Lite) and
11//! the prime suspect for the prior engine's ~30% nDCG@10 gap
12//! (`research/02a:256`). The structure here makes it unconstructible: the
13//! only public entry points that produce statistics hang off the store-wide
14//! index, and `prop_engine_bm25_equals_model` randomizes seal boundaries so
15//! any per-segment statistic diverges from the brute-force model on the
16//! first multi-segment case.
17//!
18//! # Field handling (BM25F-lite)
19//!
20//! A document is a map from field id to text. Postings are keyed by
21//! `(term, field)`, so the flat scorer sums a term's contribution across
22//! fields with weight one — reproducing the "flat" BEIR column — and the
23//! multifield scorer applies per-field weights to term frequencies and
24//! lengths. Both run the same code path with a different weight table.
25//!
26//! # No truncation, ever
27//!
28//! Task 13's guardrail is absolute: documents are never truncated before
29//! indexing, and no candidate cap exists outside task 14's bounds. An
30//! oversized field is a typed rejection at the tokenizer boundary, not a
31//! silent cut.
32
33use std::collections::{BTreeMap, HashMap};
34use std::sync::Arc;
35
36use super::bm25::{Bm25Error, CorpusStats};
37use super::postings::{Posting, PostingList, PostingsError};
38use super::sealed::SealedSegment;
39use super::tokenizer::{Analyzer, Token};
40use crate::meta::DocBitmap;
41
42/// A field identifier within a document schema.
43#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
44pub struct FieldId(pub u16);
45
46/// The default field, used when a caller indexes plain text.
47pub const DEFAULT_FIELD: FieldId = FieldId(0);
48
49/// The key a posting list hangs off: term first, so prefix scans work.
50#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
51pub struct TermKey {
52    /// The analyzed term.
53    pub term: Vec<u8>,
54    /// The field the term occurred in.
55    pub field: FieldId,
56}
57
58/// An index construction failure.
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum IndexError {
61    /// Exact live-frequency memoization refused inconsistent state or a
62    /// poisoned lock. No cache failure is interpreted as a zero frequency.
63    LiveFrequencyCache {
64        /// Failed cache invariant or synchronization boundary.
65        reason: &'static str,
66    },
67    /// A posting list rejected an append.
68    Postings(PostingsError),
69    /// The corpus had no documents or no tokens.
70    Stats(Bm25Error),
71    /// Documents were added out of row order.
72    RowsNotAscending {
73        /// The offending row.
74        row: u32,
75    },
76    /// A live-row counter named a row outside its lexical segment.
77    LiveRowOutOfRange {
78        /// Lexical segment ordinal.
79        segment: usize,
80        /// Invalid row identifier.
81        row: u32,
82        /// Dense row count of the segment.
83        row_count: u32,
84    },
85    /// A sealed segment's dense length counter omitted a valid live row.
86    LiveLengthMissing {
87        /// Lexical segment ordinal.
88        segment: usize,
89        /// Live row without a length counter.
90        row: u32,
91    },
92}
93
94impl std::fmt::Display for IndexError {
95    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            Self::LiveFrequencyCache { reason } => {
98                write!(formatter, "live document frequency cache failed: {reason}")
99            }
100            Self::Postings(error) => write!(formatter, "postings rejected: {error}"),
101            Self::Stats(error) => write!(formatter, "corpus statistics unavailable: {error}"),
102            Self::RowsNotAscending { row } => {
103                write!(formatter, "documents must arrive in row order; got {row}")
104            }
105            Self::LiveRowOutOfRange {
106                segment,
107                row,
108                row_count,
109            } => write!(
110                formatter,
111                "live lexical row {row} is outside segment {segment} row count {row_count}"
112            ),
113            Self::LiveLengthMissing { segment, row } => write!(
114                formatter,
115                "live lexical row {row} has no length counter in segment {segment}"
116            ),
117        }
118    }
119}
120
121impl std::error::Error for IndexError {}
122
123impl From<PostingsError> for IndexError {
124    fn from(error: PostingsError) -> Self {
125        Self::Postings(error)
126    }
127}
128
129impl From<Bm25Error> for IndexError {
130    fn from(error: Bm25Error) -> Self {
131        Self::Stats(error)
132    }
133}
134
135/// One document's text, by field.
136#[derive(Clone, Debug, Default, Eq, PartialEq)]
137pub struct Document {
138    fields: BTreeMap<FieldId, String>,
139}
140
141impl Document {
142    /// Creates an empty document.
143    #[must_use]
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Creates a single-field document in [`DEFAULT_FIELD`].
149    #[must_use]
150    pub fn with_text(text: &str) -> Self {
151        let mut document = Self::new();
152        document.set(DEFAULT_FIELD, text);
153        document
154    }
155
156    /// Sets one field's text, replacing any previous value.
157    pub fn set(&mut self, field: FieldId, text: &str) -> &mut Self {
158        self.fields.insert(field, text.to_owned());
159        self
160    }
161
162    /// Returns the fields, ascending by id.
163    pub fn fields(&self) -> impl Iterator<Item = (FieldId, &str)> {
164        self.fields
165            .iter()
166            .map(|(field, text)| (*field, text.as_str()))
167    }
168}
169
170/// One field's analyzed token counts, dense by row.
171///
172/// `lengths` is exactly `row_count` long at all times: a field absent from
173/// a document still occupies its row, carrying zero. That invariant is what
174/// lets the scorer borrow the array wholesale instead of rebuilding it.
175#[derive(Clone, Debug)]
176struct FieldLengths {
177    field: FieldId,
178    lengths: Vec<u32>,
179}
180
181/// Hashes term keys with xxh3, the one permitted hash family.
182#[derive(Clone, Debug, Default)]
183struct Xxh3State;
184
185impl std::hash::BuildHasher for Xxh3State {
186    type Hasher = xxhash_rust::xxh3::Xxh3;
187
188    fn build_hasher(&self) -> Self::Hasher {
189        xxhash_rust::xxh3::Xxh3::new()
190    }
191}
192
193/// One segment's postings and lengths, addressed by dense row id.
194///
195/// # Why lengths are dense arrays and not a map
196///
197/// Rows are dense and ascending by the task 07 invariant, so a
198/// `BTreeMap<(row, field), u32>` spends a pointer-chasing descent — about
199/// seventeen levels at 171,000 documents — on what an array answers with one
200/// index. The scorer performs that lookup once per scored posting, so the
201/// descent was among the largest per-posting costs in the engine.
202///
203/// # Why the term table is a hash map and not a tree
204///
205/// Accumulation looks a term up once per (document, term, field): tens of
206/// millions of probes on a real corpus, against a table with millions of
207/// entries. A B-tree descent is ~20 pointer-chasing key compares per
208/// probe; an xxh3 probe is one hash and usually one compare. The sorted
209/// view the seal needs is materialized ONCE, in [`Self::postings`],
210/// instead of being maintained on every insert.
211#[derive(Clone, Debug, Default)]
212pub struct SegmentIndex {
213    /// Term key to slot in `lists`.
214    terms: HashMap<TermKey, usize, Xxh3State>,
215    /// Posting lists, in first-seen order; `terms` holds the slots.
216    lists: Vec<PostingList>,
217    /// Per-field analyzed token counts, ascending by field id.
218    lengths: Vec<FieldLengths>,
219    row_count: u32,
220}
221
222impl SegmentIndex {
223    /// Creates an empty segment index.
224    #[must_use]
225    pub fn new() -> Self {
226        Self::default()
227    }
228
229    /// Returns the number of rows.
230    #[must_use]
231    pub const fn row_count(&self) -> u32 {
232        self.row_count
233    }
234
235    /// Returns true when the segment holds no rows.
236    #[must_use]
237    pub const fn is_empty(&self) -> bool {
238        self.row_count == 0
239    }
240
241    /// Returns the posting lists, ordered by term then field.
242    ///
243    /// The sorted view is built here, once per call, rather than being
244    /// maintained on every insert; seal is its one hot caller.
245    pub fn postings(&self) -> impl Iterator<Item = (&TermKey, &PostingList)> {
246        let mut entries: Vec<(&TermKey, usize)> =
247            self.terms.iter().map(|(key, slot)| (key, *slot)).collect();
248        entries.sort_unstable_by(|left, right| left.0.cmp(right.0));
249        entries
250            .into_iter()
251            .filter_map(|(key, slot)| self.lists.get(slot).map(|list| (key, list)))
252    }
253
254    /// Returns one term's posting list within one field.
255    #[must_use]
256    pub fn posting_list(&self, term: &[u8], field: FieldId) -> Option<&PostingList> {
257        let slot = *self.terms.get(&TermKey {
258            term: term.to_vec(),
259            field,
260        })?;
261        self.lists.get(slot)
262    }
263
264    /// Returns the analyzed token count of one row's field.
265    #[must_use]
266    pub fn field_length(&self, row: u32, field: FieldId) -> u32 {
267        self.field_lengths(field)
268            .and_then(|lengths| {
269                usize::try_from(row)
270                    .ok()
271                    .and_then(|slot| lengths.get(slot).copied())
272            })
273            .unwrap_or(0)
274    }
275
276    /// Returns the fields this segment recorded lengths for, ascending.
277    pub fn fields(&self) -> impl Iterator<Item = FieldId> + '_ {
278        self.lengths.iter().map(|entry| entry.field)
279    }
280
281    /// Returns one field's dense length array, if the field was ever set.
282    ///
283    /// The slice is exactly [`Self::row_count`] long, so a caller scoring a
284    /// single unweighted field can borrow it rather than rebuild it. A
285    /// linear scan over the field table is right here: a schema has a
286    /// handful of fields, and a map would trade an index for a descent.
287    #[must_use]
288    pub fn field_lengths(&self, field: FieldId) -> Option<&[u32]> {
289        self.lengths
290            .iter()
291            .find(|entry| entry.field == field)
292            .map(|entry| entry.lengths.as_slice())
293    }
294
295    /// Returns the analyzed token count of one row across every field.
296    #[must_use]
297    pub fn document_length(&self, row: u32) -> u32 {
298        let Ok(slot) = usize::try_from(row) else {
299            return 0;
300        };
301        self.lengths
302            .iter()
303            .filter_map(|entry| entry.lengths.get(slot).copied())
304            .fold(0_u32, u32::saturating_add)
305    }
306
307    /// Returns the segment's total analyzed token count.
308    #[must_use]
309    pub fn total_tokens(&self) -> u64 {
310        self.lengths
311            .iter()
312            .flat_map(|entry| entry.lengths.iter())
313            .map(|length| u64::from(*length))
314            .sum()
315    }
316
317    pub(crate) fn resident_bytes(&self) -> usize {
318        let term_slots = self
319            .terms
320            .capacity()
321            .saturating_mul(std::mem::size_of::<(TermKey, usize)>().saturating_add(1));
322        let term_bytes = self
323            .terms
324            .keys()
325            .map(|key| key.term.capacity())
326            .fold(0_usize, usize::saturating_add);
327        let list_bytes = self
328            .lists
329            .capacity()
330            .saturating_mul(std::mem::size_of::<PostingList>())
331            .saturating_add(
332                self.lists
333                    .iter()
334                    .map(PostingList::resident_bytes)
335                    .fold(0_usize, usize::saturating_add),
336            );
337        let length_bytes = self
338            .lengths
339            .capacity()
340            .saturating_mul(std::mem::size_of::<FieldLengths>())
341            .saturating_add(
342                self.lengths
343                    .iter()
344                    .map(|entry| entry.lengths.capacity().saturating_mul(4))
345                    .fold(0_usize, usize::saturating_add),
346            );
347        term_slots
348            .saturating_add(term_bytes)
349            .saturating_add(list_bytes)
350            .saturating_add(length_bytes)
351    }
352
353    /// Records one row's analyzed length for one field.
354    ///
355    /// Pads the field's array with zeros for any earlier row that did not
356    /// carry the field, which is what keeps every array dense.
357    fn set_field_length(&mut self, row: u32, field: FieldId, length: u32) {
358        let Ok(slot) = usize::try_from(row) else {
359            return;
360        };
361        let position = match self.lengths.iter().position(|entry| entry.field == field) {
362            Some(position) => position,
363            None => {
364                // Fields stay ascending so the table has a stable order.
365                let position = self
366                    .lengths
367                    .iter()
368                    .position(|entry| entry.field > field)
369                    .unwrap_or(self.lengths.len());
370                self.lengths.insert(
371                    position,
372                    FieldLengths {
373                        field,
374                        lengths: Vec::new(),
375                    },
376                );
377                position
378            }
379        };
380        let Some(entry) = self.lengths.get_mut(position) else {
381            return;
382        };
383        entry.lengths.resize(slot, 0);
384        entry.lengths.push(length);
385    }
386
387    /// Extends every field array to cover `rows` rows.
388    fn pad_lengths_to(&mut self, rows: usize) {
389        for entry in &mut self.lengths {
390            entry.lengths.resize(rows, 0);
391        }
392    }
393
394    /// Appends one analyzed document as the next dense row.
395    ///
396    /// # Errors
397    ///
398    /// Returns [`IndexError::Postings`] when the analyzer produces a token
399    /// stream a posting list refuses, which would be a tokenizer contract
400    /// violation rather than bad input.
401    pub fn push_document(
402        &mut self,
403        analyzer: &Analyzer,
404        document: &Document,
405    ) -> Result<u32, IndexError> {
406        let row = self.row_count;
407        for (field, text) in document.fields() {
408            let tokens = analyzer.analyze(text);
409            self.accumulate(row, field, &tokens)?;
410        }
411        self.finish_row();
412        Ok(row)
413    }
414
415    /// Appends many documents, analyzing them in parallel.
416    ///
417    /// The result is identical to pushing the same documents one at a time
418    /// in slice order: analysis is independent per document and runs on
419    /// explicit scoped threads, while accumulation happens strictly in row
420    /// order on the calling thread — so nothing about the outcome depends
421    /// on scheduling, and the single-writer invariant is untouched.
422    ///
423    /// `threads` is the caller's policy; one or fewer runs sequentially.
424    /// Analysis is batched so the in-flight token working set stays
425    /// proportional to the batch, never to the corpus.
426    ///
427    /// # Errors
428    ///
429    /// As [`Self::push_document`]. On an error, documents before the
430    /// failing one remain indexed, exactly as a sequential loop would
431    /// leave them.
432    pub fn push_documents(
433        &mut self,
434        analyzer: &Analyzer,
435        documents: &[Document],
436        threads: usize,
437    ) -> Result<(), IndexError> {
438        let workers = threads.max(1).min(documents.len().max(1));
439        if workers <= 1 {
440            for document in documents {
441                self.push_document(analyzer, document)?;
442            }
443            return Ok(());
444        }
445        let batch_size = workers.saturating_mul(32).max(1);
446        for batch in documents.chunks(batch_size) {
447            let mut analyzed: Vec<Vec<(FieldId, Vec<Token>)>> = Vec::new();
448            analyzed.resize_with(batch.len(), Vec::new);
449            let stride = batch.len().div_ceil(workers);
450            std::thread::scope(|scope| {
451                let mut rest = analyzed.as_mut_slice();
452                let mut offset = 0_usize;
453                while !rest.is_empty() {
454                    let take = stride.min(rest.len());
455                    let (mine, tail) = rest.split_at_mut(take);
456                    rest = tail;
457                    let end = offset.saturating_add(take);
458                    let docs = batch.get(offset..end).unwrap_or(&[]);
459                    offset = end;
460                    scope.spawn(move || {
461                        for (slot, document) in mine.iter_mut().zip(docs) {
462                            *slot = document
463                                .fields()
464                                .map(|(field, text)| (field, analyzer.analyze(text)))
465                                .collect();
466                        }
467                    });
468                }
469            });
470            for fields in &analyzed {
471                let row = self.row_count;
472                for (field, tokens) in fields {
473                    self.accumulate(row, *field, tokens)?;
474                }
475                self.finish_row();
476            }
477        }
478        Ok(())
479    }
480
481    /// Folds one field's analyzed tokens into the postings for one row.
482    fn accumulate(&mut self, row: u32, field: FieldId, tokens: &[Token]) -> Result<(), IndexError> {
483        // Position count is the analyzed length: stacked variants share
484        // a position and must not inflate the document length, or avgdl
485        // stops matching the unit being scored.
486        let length = tokens
487            .iter()
488            .map(|token| token.position)
489            .max()
490            .map_or(0, |highest| highest.saturating_add(1));
491        self.set_field_length(row, field, length);
492
493        // Sort-based grouping over borrowed terms replaces the per-document
494        // `BTreeMap<Vec<u8>, Vec<u32>>`: no map to build and tear down per
495        // document, no allocation per (document, term). `str` compares
496        // byte-wise, so groups arrive in exactly the order the map's keys
497        // did, and a group's positions arrive already sorted.
498        let mut scratch: Vec<(&str, u32)> = tokens
499            .iter()
500            .map(|token| (token.term.as_str(), token.position))
501            .collect();
502        scratch.sort_unstable();
503
504        // One reusable probe key per field: lookups borrow it, and only a
505        // term new to the segment pays an owned copy.
506        let mut probe = TermKey {
507            term: Vec::new(),
508            field,
509        };
510        let mut start = 0_usize;
511        while let Some((term, _)) = scratch.get(start).copied() {
512            let mut end = start.saturating_add(1);
513            while scratch.get(end).is_some_and(|(next, _)| *next == term) {
514                end = end.saturating_add(1);
515            }
516            let group = scratch.get(start..end).unwrap_or(&[]);
517            let mut positions: Vec<u32> = Vec::with_capacity(group.len());
518            for (_, position) in group {
519                if positions.last() != Some(position) {
520                    positions.push(*position);
521                }
522            }
523            let tf = u32::try_from(positions.len()).unwrap_or(u32::MAX);
524            probe.term.clear();
525            probe.term.extend_from_slice(term.as_bytes());
526            let posting = Posting {
527                docid: row,
528                tf,
529                positions,
530            };
531            match self.terms.get(&probe).copied() {
532                Some(slot) => {
533                    // The map only ever hands out slots minted below, so
534                    // the lookup cannot miss; the `if let` is the panic-free
535                    // spelling of that impossibility.
536                    if let Some(list) = self.lists.get_mut(slot) {
537                        list.push(posting)?;
538                    }
539                }
540                None => {
541                    let mut list = PostingList::new();
542                    list.push(posting)?;
543                    let slot = self.lists.len();
544                    self.lists.push(list);
545                    self.terms.insert(
546                        TermKey {
547                            term: probe.term.clone(),
548                            field,
549                        },
550                        slot,
551                    );
552                }
553            }
554            start = end;
555        }
556        Ok(())
557    }
558
559    /// Seals the row: every field array is padded to cover it.
560    fn finish_row(&mut self) {
561        self.row_count = self.row_count.saturating_add(1);
562        self.pad_lengths_to(usize::try_from(self.row_count).unwrap_or(usize::MAX));
563    }
564}
565
566/// The store-wide lexical index across every segment.
567///
568/// # Segments here are SEALED
569///
570/// [`push_segment`](Self::push_segment) encodes the active segment into the
571/// persisted posting layout and keeps only that. There is therefore exactly
572/// one scoring path in the engine — over bytes — rather than an in-memory
573/// one that ships and a persisted one that is only ever tested.
574#[derive(Clone, Debug, Default)]
575pub struct LexicalIndex {
576    segments: Vec<Arc<SealedSegment>>,
577    statistics: Vec<Arc<LiveSegmentStatistics>>,
578    has_frequency_cache: bool,
579}
580
581#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
582struct LiveSegmentCounters {
583    documents: u64,
584    tokens: u64,
585}
586
587/// Validated live membership and counters, independent of source ordinal.
588#[derive(Clone, Debug)]
589pub(crate) struct LiveSegmentStatistics {
590    pub(crate) rows: DocBitmap,
591    counters: LiveSegmentCounters,
592    pub(crate) frequency_cache: Option<Arc<super::live_df::LiveFrequencyCache>>,
593}
594
595impl LiveSegmentStatistics {
596    pub(crate) fn build_controlled<E: From<IndexError>>(
597        ordinal: usize,
598        segment: &SealedSegment,
599        rows: &DocBitmap,
600        work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
601    ) -> Result<Self, E> {
602        work.check_now()?;
603        let value = Self {
604            counters: live_segment_counters_controlled(ordinal, segment, rows, work)?,
605            rows: rows.clone(),
606            frequency_cache: None,
607        };
608        work.check_now()?;
609        Ok(value)
610    }
611
612    pub(crate) fn build(
613        ordinal: usize,
614        segment: &SealedSegment,
615        rows: &DocBitmap,
616    ) -> Result<Self, IndexError> {
617        Self::build_controlled(
618            ordinal,
619            segment,
620            rows,
621            &mut super::control::WorkCheck::new(|| Ok(())),
622        )
623    }
624
625    pub(crate) fn allocation_bytes(rows: &DocBitmap) -> Option<usize> {
626        std::mem::size_of::<Self>()
627            .checked_add(2 * std::mem::size_of::<usize>())?
628            .checked_add(rows.resident_bytes()?)
629    }
630}
631
632impl LexicalIndex {
633    /// Creates an empty index.
634    #[must_use]
635    pub fn new() -> Self {
636        Self::default()
637    }
638
639    /// Seals one active segment and appends it.
640    ///
641    /// # Errors
642    ///
643    /// Returns [`IndexError::Postings`] when the encoder rejects the
644    /// segment's geometry or refuses to read back what it just wrote. Both
645    /// are broken-writer conditions, and they surface here rather than
646    /// becoming a decode fault inside a later query.
647    pub fn push_segment(&mut self, segment: SegmentIndex) -> Result<(), IndexError> {
648        self.push_sealed(SealedSegment::seal(&segment)?);
649        Ok(())
650    }
651
652    /// Appends an already-sealed segment.
653    pub fn push_sealed(&mut self, segment: SealedSegment) {
654        self.push_shared(Arc::new(segment));
655    }
656
657    fn push_shared(&mut self, segment: Arc<SealedSegment>) {
658        self.statistics.push(Arc::new(LiveSegmentStatistics {
659            rows: DocBitmap::full(segment.row_count()),
660            frequency_cache: None,
661            counters: LiveSegmentCounters {
662                documents: u64::from(segment.row_count()),
663                tokens: segment.total_tokens(),
664            },
665        }));
666        self.segments.push(segment);
667    }
668
669    /// Appends an already-sealed segment with exact live-row counters.
670    ///
671    /// The row set is the segment tombstone state's live bitmap, not a query
672    /// filter. Capturing it here keeps BM25 corpus statistics independent of
673    /// later metadata allow-lists while avoiding any posting-list rewrite.
674    ///
675    /// # Errors
676    ///
677    /// Returns [`IndexError::LiveRowOutOfRange`] when the tombstone state
678    /// names a row outside the sealed segment, or
679    /// [`IndexError::LiveLengthMissing`] when a sealed length counter violates
680    /// its dense-row invariant.
681    pub fn push_sealed_with_live_rows(
682        &mut self,
683        segment: SealedSegment,
684        live_rows: &DocBitmap,
685    ) -> Result<(), IndexError> {
686        self.push_shared_with_live_rows(Arc::new(segment), live_rows)
687    }
688
689    pub(crate) fn push_shared_with_live_rows(
690        &mut self,
691        segment: Arc<SealedSegment>,
692        live_rows: &DocBitmap,
693    ) -> Result<(), IndexError> {
694        let statistics = Arc::new(LiveSegmentStatistics::build(
695            self.segments.len(),
696            &segment,
697            live_rows,
698        )?);
699        self.push_shared_with_statistics(segment, statistics);
700        Ok(())
701    }
702
703    /// Only a validated contribution can enter this path; callers cannot supply
704    /// independent counters or change the membership behind the shared value.
705    pub(crate) fn push_shared_with_statistics(
706        &mut self,
707        segment: Arc<SealedSegment>,
708        statistics: Arc<LiveSegmentStatistics>,
709    ) {
710        self.has_frequency_cache |= statistics.frequency_cache.is_some();
711        self.segments.push(segment);
712        self.statistics.push(statistics);
713    }
714
715    /// Returns the segments in seal order.
716    #[must_use]
717    pub fn segments(&self) -> &[Arc<SealedSegment>] {
718        &self.segments
719    }
720
721    /// Bytes for the index spines. Cached contributions own and account their
722    /// shared membership/counters separately for the full Arc lifetime.
723    pub(crate) fn resident_spine_bytes(&self) -> Option<usize> {
724        self.segments
725            .capacity()
726            .checked_mul(std::mem::size_of::<Arc<SealedSegment>>())?
727            .checked_add(
728                self.statistics
729                    .capacity()
730                    .checked_mul(std::mem::size_of::<Arc<LiveSegmentStatistics>>())?,
731            )
732    }
733
734    /// Iterates the analyzed vocabulary of every segment.
735    ///
736    /// Terms may repeat across segments; callers that expand a structured
737    /// query must impose their own deterministic de-duplication.
738    pub fn terms(&self) -> impl Iterator<Item = &[u8]> {
739        self.segments.iter().flat_map(|segment| segment.terms())
740    }
741
742    pub(crate) fn vocabulary_terms(&self) -> impl Iterator<Item = (&[u8], FieldId)> + Clone {
743        self.segments
744            .iter()
745            .flat_map(|segment| segment.vocabulary_terms())
746    }
747
748    /// Returns the store-wide document count.
749    #[must_use]
750    pub fn document_count(&self) -> u64 {
751        self.statistics
752            .iter()
753            .map(|statistics| statistics.counters.documents)
754            .fold(0_u64, u64::saturating_add)
755    }
756
757    /// Returns the store-wide analyzed token count.
758    #[must_use]
759    pub fn total_tokens(&self) -> u64 {
760        self.statistics
761            .iter()
762            .map(|statistics| statistics.counters.tokens)
763            .fold(0_u64, u64::saturating_add)
764    }
765
766    /// Returns the store-wide corpus statistics.
767    ///
768    /// This is the ONLY source of `N` and `avgdl` for scoring. There is
769    /// deliberately no per-segment equivalent.
770    ///
771    /// # Errors
772    ///
773    /// Returns [`IndexError::Stats`] when the index holds no documents or
774    /// no tokens, because `avgdl` is undefined then.
775    pub fn corpus_stats(&self) -> Result<CorpusStats, IndexError> {
776        #[cfg(any(test, feature = "test-support"))]
777        super::preparation_observer::corpus_statistics();
778        Ok(CorpusStats::new(
779            self.document_count(),
780            self.total_tokens(),
781        )?)
782    }
783
784    /// Returns the store-wide document frequency of one term.
785    ///
786    /// Summed across every segment and every requested field. A document
787    /// containing the term in two fields counts once, which is what makes
788    /// this a *document* frequency rather than a posting count. Segments
789    /// without tombstones answer from their sealed dictionary.
790    /// Segments with tombstones decode document identifiers and count only
791    /// rows in the same live bitmap used for `N` and `avgdl`.
792    #[must_use]
793    pub fn document_frequency(&self, term: &[u8], fields: &[FieldId]) -> u32 {
794        #[cfg(any(test, feature = "test-support"))]
795        super::preparation_observer::frequency();
796        self.segments
797            .iter()
798            .zip(&self.statistics)
799            .map(|(segment, statistics)| {
800                let live_rows = &statistics.rows;
801                if live_rows.cardinality() == u64::from(segment.row_count()) {
802                    segment.document_frequency(term, fields)
803                } else {
804                    segment.live_document_frequency(term, fields, live_rows)
805                }
806            })
807            .fold(0_u32, u32::saturating_add)
808    }
809
810    /// Scoring can report cache synchronization errors without changing the
811    /// public infallible, uncached statistics inspection method. Callers pass
812    /// the canonical field list from FieldWeights.
813    #[inline]
814    pub(crate) fn prepared_document_frequency(
815        &self,
816        term: &[u8],
817        fields: &[FieldId],
818    ) -> Result<u32, IndexError> {
819        // Preserve the original dictionary/statistics loop when no contribution
820        // owns a cache. Cache synchronization and fallible accumulation belong
821        // only to snapshots that can actually use them.
822        if !self.has_frequency_cache {
823            return Ok(self.document_frequency(term, fields));
824        }
825        self.cached_document_frequency(term, fields)
826    }
827
828    pub(crate) fn prepared_document_frequency_controlled<E>(
829        &self,
830        term: &[u8],
831        fields: &[FieldId],
832        mut check: impl FnMut() -> Result<(), E>,
833    ) -> Result<u32, super::search::ControlledSearchError<E>> {
834        use super::search::ControlledSearchError;
835        #[cfg(any(test, feature = "test-support"))]
836        super::preparation_observer::frequency();
837        check().map_err(ControlledSearchError::Control)?;
838        let mut total = 0_u32;
839        for (segment, statistics) in self.segments.iter().zip(&self.statistics) {
840            let live = &statistics.rows;
841            let frequency = if live.cardinality() == u64::from(segment.row_count()) {
842                segment
843                    .document_frequency_controlled(term, fields, &mut check)
844                    .map_err(ControlledSearchError::Control)?
845            } else if let Some(cache) = &statistics.frequency_cache {
846                cache.frequency_controlled(segment, live, term, fields, &mut check)?
847            } else {
848                segment
849                    .live_document_frequency_controlled(term, fields, live, &mut check)
850                    .map_err(ControlledSearchError::Control)?
851            };
852            total = total.saturating_add(frequency);
853        }
854        check().map_err(ControlledSearchError::Control)?;
855        Ok(total)
856    }
857
858    fn cached_document_frequency(
859        &self,
860        term: &[u8],
861        fields: &[FieldId],
862    ) -> Result<u32, IndexError> {
863        #[cfg(any(test, feature = "test-support"))]
864        super::preparation_observer::frequency();
865        self.segments.iter().zip(&self.statistics).try_fold(
866            0_u32,
867            |total, (segment, statistics)| {
868                let live = &statistics.rows;
869                let frequency = if live.cardinality() == u64::from(segment.row_count()) {
870                    segment.document_frequency(term, fields)
871                } else if let Some(cache) = &statistics.frequency_cache {
872                    cache.frequency(segment, live, term, fields)?
873                } else {
874                    segment.live_document_frequency(term, fields, live)
875                };
876                Ok(total.saturating_add(frequency))
877            },
878        )
879    }
880}
881
882fn live_segment_counters_controlled<E: From<IndexError>>(
883    ordinal: usize,
884    segment: &SealedSegment,
885    live_rows: &DocBitmap,
886    work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
887) -> Result<LiveSegmentCounters, E> {
888    work.check_now()?;
889    if let Some(row) = live_rows.first_at_or_after(segment.row_count()) {
890        return Err(IndexError::LiveRowOutOfRange {
891            segment: ordinal,
892            row,
893            row_count: segment.row_count(),
894        }
895        .into());
896    }
897    let tokens =
898        live_rows.iter().try_fold(0_u64, |total, row| {
899            work.step()?;
900            #[cfg(any(test, feature = "test-support"))]
901            super::preparation_observer::live_statistics_row();
902            let slot = usize::try_from(row).map_err(|_| IndexError::LiveLengthMissing {
903                segment: ordinal,
904                row,
905            })?;
906            let length = segment.total_lengths().get(slot).copied().ok_or(
907                IndexError::LiveLengthMissing {
908                    segment: ordinal,
909                    row,
910                },
911            )?;
912            Ok::<u64, E>(total.saturating_add(u64::from(length)))
913        })?;
914    work.check_now()?;
915    Ok(LiveSegmentCounters {
916        documents: live_rows.cardinality(),
917        tokens,
918    })
919}
920
921#[cfg(test)]
922#[allow(
923    clippy::expect_used,
924    clippy::indexing_slicing,
925    clippy::panic,
926    clippy::unwrap_used
927)]
928mod tests {
929
930    #[test]
931    fn parallel_analysis_is_identical_to_the_sequential_loop() {
932        // push_documents claims scheduling independence; this holds it to
933        // the sequential oracle exactly -- every posting, every position,
934        // every length array -- across thread counts and a document count
935        // chosen to leave a ragged final batch.
936        let analyzer = analyzer();
937        let documents: Vec<Document> = (0..97)
938            .map(|index| {
939                let mut document = Document::new();
940                document.set(
941                    FieldId(0),
942                    &format!("alpha beta{} gamma-{index} 401k", index % 7),
943                );
944                document.set(FieldId(1), &format!("delta epsilon{} the quick", index % 5));
945                document
946            })
947            .collect();
948        let mut sequential = SegmentIndex::new();
949        for document in &documents {
950            sequential
951                .push_document(&analyzer, document)
952                .expect("indexable");
953        }
954        for threads in [1_usize, 3, 8] {
955            let mut parallel = SegmentIndex::new();
956            parallel
957                .push_documents(&analyzer, &documents, threads)
958                .expect("indexable");
959            assert_eq!(parallel.row_count(), sequential.row_count());
960            let ours: Vec<_> = parallel.postings().collect();
961            let oracle: Vec<_> = sequential.postings().collect();
962            assert_eq!(
963                ours.len(),
964                oracle.len(),
965                "term count diverged at {threads} threads"
966            );
967            for ((key, list), (oracle_key, oracle_list)) in ours.iter().zip(&oracle) {
968                assert_eq!(key, oracle_key);
969                assert_eq!(
970                    list.postings(),
971                    oracle_list.postings(),
972                    "postings diverged for {:?} at {threads} threads",
973                    String::from_utf8_lossy(&key.term)
974                );
975            }
976            for field in [FieldId(0), FieldId(1)] {
977                assert_eq!(
978                    parallel.field_lengths(field),
979                    sequential.field_lengths(field),
980                    "lengths diverged at {threads} threads"
981                );
982            }
983        }
984    }
985
986    use super::*;
987    use crate::fts::tokenizer::{Profile, TokenizerConfig};
988
989    #[test]
990    fn astra_18_live_statistics_cancel_inside_row_walk() {
991        #[derive(Debug)]
992        enum Error {
993            Index(IndexError),
994            Cancelled,
995        }
996        impl From<IndexError> for Error {
997            fn from(error: IndexError) -> Self {
998                Self::Index(error)
999            }
1000        }
1001        let segment = SealedSegment::seal(&segment_of(&analyzer(), &vec!["alpha beta"; 4_097]))
1002            .expect("sealed row fixture");
1003        let live = DocBitmap::from_ids(0..4_096);
1004        super::super::preparation_observer::begin();
1005        let mut work = super::super::control::WorkCheck::new(|| {
1006            if super::super::preparation_observer::live_statistics_rows() >= 64 {
1007                Err(Error::Cancelled)
1008            } else {
1009                Ok(())
1010            }
1011        });
1012        let result = LiveSegmentStatistics::build_controlled(7, &segment, &live, &mut work);
1013        let visited = super::super::preparation_observer::live_statistics_rows();
1014        super::super::preparation_observer::take();
1015        println!("live statistics rows={visited}");
1016        assert!(matches!(result, Err(Error::Cancelled)));
1017        assert!((64..=128).contains(&visited));
1018        let mut clean = super::super::control::WorkCheck::new(|| Ok::<(), Error>(()));
1019        let value = LiveSegmentStatistics::build_controlled(7, &segment, &live, &mut clean)
1020            .expect("fresh control");
1021        assert_eq!(value.counters.documents, 4_096);
1022        assert_eq!(value.counters.tokens, 8_192);
1023        assert_eq!(value.rows, live);
1024        assert!(value.frequency_cache.is_none());
1025        assert!(matches!(
1026            LiveSegmentStatistics::build_controlled(
1027                7,
1028                &segment,
1029                &DocBitmap::from_ids([4_097]),
1030                &mut clean
1031            ),
1032            Err(Error::Index(IndexError::LiveRowOutOfRange {
1033                segment: 7,
1034                row: 4_097,
1035                row_count: 4_097
1036            }))
1037        ));
1038    }
1039
1040    fn analyzer() -> Analyzer {
1041        Analyzer::new(TokenizerConfig::text_default()).expect("valid config")
1042    }
1043
1044    fn code_analyzer() -> Analyzer {
1045        Analyzer::new(Profile::Code.config()).expect("valid config")
1046    }
1047
1048    fn segment_of(analyzer: &Analyzer, texts: &[&str]) -> SegmentIndex {
1049        let mut segment = SegmentIndex::new();
1050        for text in texts {
1051            segment
1052                .push_document(analyzer, &Document::with_text(text))
1053                .expect("indexable");
1054        }
1055        segment
1056    }
1057
1058    #[test]
1059    fn rows_are_dense_and_ascending() {
1060        let analyzer = analyzer();
1061        let mut segment = SegmentIndex::new();
1062        for (expected, text) in ["alpha", "beta", "gamma"].into_iter().enumerate() {
1063            let row = segment
1064                .push_document(&analyzer, &Document::with_text(text))
1065                .expect("indexable");
1066            assert_eq!(row, u32::try_from(expected).expect("small"));
1067        }
1068        assert_eq!(segment.row_count(), 3);
1069    }
1070
1071    #[test]
1072    fn document_frequency_is_summed_across_segments_not_per_segment() {
1073        let analyzer = code_analyzer();
1074        let mut index = LexicalIndex::new();
1075        index
1076            .push_segment(segment_of(&analyzer, &["engine", "engine"]))
1077            .expect("seals");
1078        index
1079            .push_segment(segment_of(&analyzer, &["engine", "other"]))
1080            .expect("seals");
1081        assert_eq!(index.document_count(), 4);
1082        // Three of the four documents contain "engine", across two segments.
1083        assert_eq!(index.document_frequency(b"engine", &[DEFAULT_FIELD]), 3);
1084    }
1085
1086    #[test]
1087    fn a_term_in_two_fields_of_one_document_counts_once() {
1088        let analyzer = code_analyzer();
1089        let mut segment = SegmentIndex::new();
1090        let mut document = Document::new();
1091        document.set(FieldId(0), "engine");
1092        document.set(FieldId(1), "engine");
1093        segment
1094            .push_document(&analyzer, &document)
1095            .expect("indexable");
1096        let mut index = LexicalIndex::new();
1097        index.push_segment(segment).expect("seals");
1098        assert_eq!(
1099            index.document_frequency(b"engine", &[FieldId(0), FieldId(1)]),
1100            1
1101        );
1102    }
1103
1104    #[test]
1105    fn corpus_stats_span_every_segment() {
1106        let analyzer = code_analyzer();
1107        let mut index = LexicalIndex::new();
1108        index
1109            .push_segment(segment_of(&analyzer, &["a b c"]))
1110            .expect("seals");
1111        index
1112            .push_segment(segment_of(&analyzer, &["d e f g h"]))
1113            .expect("seals");
1114        let stats = index.corpus_stats().expect("two documents");
1115        assert_eq!(stats.document_count(), 2);
1116        assert_eq!(stats.total_tokens(), 8);
1117        assert!((stats.average_document_length() - 4.0).abs() < 1e-12);
1118    }
1119
1120    #[test]
1121    fn live_row_counters_and_document_frequency_exclude_tombstones() {
1122        let analyzer = code_analyzer();
1123        let sealed = SealedSegment::seal(&segment_of(
1124            &analyzer,
1125            &["engine padding padding", "engine"],
1126        ))
1127        .expect("seal lexical segment");
1128        let live_rows = DocBitmap::from_ids([1]);
1129        let mut index = LexicalIndex::new();
1130        index
1131            .push_sealed_with_live_rows(sealed, &live_rows)
1132            .expect("valid live rows");
1133
1134        let stats = index.corpus_stats().expect("one live document");
1135        assert_eq!(stats.document_count(), 1);
1136        assert_eq!(stats.total_tokens(), 1);
1137        assert_eq!(
1138            index.document_frequency(b"engine", &[DEFAULT_FIELD]),
1139            1,
1140            "document frequency must use the same live rows as corpus stats"
1141        );
1142    }
1143
1144    #[test]
1145    fn an_empty_index_refuses_to_produce_statistics() {
1146        let index = LexicalIndex::new();
1147        assert!(index.corpus_stats().is_err());
1148    }
1149
1150    #[test]
1151    fn document_length_counts_positions_not_tokens() {
1152        // Stacked variants share a position, so an identifier that emits
1153        // three tokens at one position must not count as three tokens of
1154        // length, or avgdl stops matching the unit being scored.
1155        let analyzer = code_analyzer();
1156        let mut segment = SegmentIndex::new();
1157        segment
1158            .push_document(&analyzer, &Document::with_text("put_if_match"))
1159            .expect("indexable");
1160        // Positions 0, 1, 2 for put / if / match.
1161        assert_eq!(segment.document_length(0), 3);
1162    }
1163
1164    #[test]
1165    fn field_lengths_are_tracked_separately_and_summed() {
1166        let analyzer = code_analyzer();
1167        let mut segment = SegmentIndex::new();
1168        let mut document = Document::new();
1169        document.set(FieldId(0), "one two");
1170        document.set(FieldId(1), "three four five");
1171        segment
1172            .push_document(&analyzer, &document)
1173            .expect("indexable");
1174        assert_eq!(segment.field_length(0, FieldId(0)), 2);
1175        assert_eq!(segment.field_length(0, FieldId(1)), 3);
1176        assert_eq!(segment.document_length(0), 5);
1177        assert_eq!(segment.total_tokens(), 5);
1178        assert_eq!(segment.field_length(0, FieldId(9)), 0);
1179    }
1180
1181    #[test]
1182    fn posting_lists_carry_positions() {
1183        let analyzer = code_analyzer();
1184        let segment = segment_of(&analyzer, &["alpha beta alpha"]);
1185        let list = segment
1186            .posting_list(b"alpha", DEFAULT_FIELD)
1187            .expect("alpha present");
1188        let posting = list.postings().first().expect("one posting");
1189        assert_eq!(posting.tf, 2);
1190        assert_eq!(posting.positions, vec![0, 2]);
1191    }
1192
1193    #[test]
1194    fn an_absent_term_has_no_posting_list_and_zero_frequency() {
1195        let analyzer = code_analyzer();
1196        let segment = segment_of(&analyzer, &["alpha"]);
1197        assert!(segment.posting_list(b"omega", DEFAULT_FIELD).is_none());
1198        let mut index = LexicalIndex::new();
1199        index.push_segment(segment).expect("seals");
1200        assert_eq!(index.document_frequency(b"omega", &[DEFAULT_FIELD]), 0);
1201    }
1202
1203    #[test]
1204    fn errors_render_useful_text() {
1205        assert!(
1206            IndexError::RowsNotAscending { row: 3 }
1207                .to_string()
1208                .contains("row order")
1209        );
1210        assert!(
1211            IndexError::Stats(Bm25Error::EmptyCorpus)
1212                .to_string()
1213                .contains("statistics")
1214        );
1215        assert!(
1216            IndexError::Postings(PostingsError::ZeroTermFrequency)
1217                .to_string()
1218                .contains("postings")
1219        );
1220    }
1221}