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    /// The weighted cursor scheduler referenced an absent cursor slot.
62    CursorSlotMissing {
63        /// Invalid scheduler slot.
64        slot: usize,
65    },
66    /// Exact live-frequency memoization refused inconsistent state or a
67    /// poisoned lock. No cache failure is interpreted as a zero frequency.
68    LiveFrequencyCache {
69        /// Failed cache invariant or synchronization boundary.
70        reason: &'static str,
71    },
72    /// A posting list rejected an append.
73    Postings(PostingsError),
74    /// The corpus had no documents or no tokens.
75    Stats(Bm25Error),
76    /// Documents were added out of row order.
77    RowsNotAscending {
78        /// The offending row.
79        row: u32,
80    },
81    /// A live-row counter named a row outside its lexical segment.
82    LiveRowOutOfRange {
83        /// Lexical segment ordinal.
84        segment: usize,
85        /// Invalid row identifier.
86        row: u32,
87        /// Dense row count of the segment.
88        row_count: u32,
89    },
90    /// A sealed segment's dense length counter omitted a valid live row.
91    LiveLengthMissing {
92        /// Lexical segment ordinal.
93        segment: usize,
94        /// Live row without a length counter.
95        row: u32,
96    },
97    /// A replacement named a row the segment does not hold.
98    ReplacedRowOutOfRange {
99        /// Invalid row identifier.
100        row: u32,
101        /// Dense row count of the segment.
102        row_count: u32,
103    },
104    /// A replaced row's previous document names a term the row has no
105    /// posting for, so the index was not built from that document.
106    ReplacedPostingMissing {
107        /// The replaced row.
108        row: u32,
109    },
110}
111
112impl std::fmt::Display for IndexError {
113    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            Self::CursorSlotMissing { slot } => {
116                write!(formatter, "weighted cursor slot {slot} is absent")
117            }
118            Self::LiveFrequencyCache { reason } => {
119                write!(formatter, "live document frequency cache failed: {reason}")
120            }
121            Self::Postings(error) => write!(formatter, "postings rejected: {error}"),
122            Self::Stats(error) => write!(formatter, "corpus statistics unavailable: {error}"),
123            Self::RowsNotAscending { row } => {
124                write!(formatter, "documents must arrive in row order; got {row}")
125            }
126            Self::LiveRowOutOfRange {
127                segment,
128                row,
129                row_count,
130            } => write!(
131                formatter,
132                "live lexical row {row} is outside segment {segment} row count {row_count}"
133            ),
134            Self::LiveLengthMissing { segment, row } => write!(
135                formatter,
136                "live lexical row {row} has no length counter in segment {segment}"
137            ),
138            Self::ReplacedRowOutOfRange { row, row_count } => write!(
139                formatter,
140                "replaced lexical row {row} is outside the segment's {row_count} rows"
141            ),
142            Self::ReplacedPostingMissing { row } => write!(
143                formatter,
144                "replaced lexical row {row} has no posting for a term of its previous document"
145            ),
146        }
147    }
148}
149
150impl std::error::Error for IndexError {}
151
152impl From<PostingsError> for IndexError {
153    fn from(error: PostingsError) -> Self {
154        Self::Postings(error)
155    }
156}
157
158impl From<Bm25Error> for IndexError {
159    fn from(error: Bm25Error) -> Self {
160        Self::Stats(error)
161    }
162}
163
164/// One document's text, by field.
165#[derive(Clone, Debug, Default, Eq, PartialEq)]
166pub struct Document {
167    fields: BTreeMap<FieldId, String>,
168}
169
170impl Document {
171    /// Creates an empty document.
172    #[must_use]
173    pub fn new() -> Self {
174        Self::default()
175    }
176
177    /// Creates a single-field document in [`DEFAULT_FIELD`].
178    #[must_use]
179    pub fn with_text(text: &str) -> Self {
180        let mut document = Self::new();
181        document.set(DEFAULT_FIELD, text);
182        document
183    }
184
185    /// Sets one field's text, replacing any previous value.
186    pub fn set(&mut self, field: FieldId, text: &str) -> &mut Self {
187        self.fields.insert(field, text.to_owned());
188        self
189    }
190
191    /// Returns the fields, ascending by id.
192    pub fn fields(&self) -> impl Iterator<Item = (FieldId, &str)> {
193        self.fields
194            .iter()
195            .map(|(field, text)| (*field, text.as_str()))
196    }
197}
198
199/// One field's analyzed token counts, dense by row.
200///
201/// `lengths` is exactly `row_count` long at all times: a field absent from
202/// a document still occupies its row, carrying zero. That invariant is what
203/// lets the scorer borrow the array wholesale instead of rebuilding it.
204#[derive(Clone, Debug)]
205struct FieldLengths {
206    field: FieldId,
207    lengths: Vec<u32>,
208}
209
210/// Hashes term keys with xxh3, the one permitted hash family.
211#[derive(Clone, Debug, Default)]
212struct Xxh3State;
213
214impl std::hash::BuildHasher for Xxh3State {
215    type Hasher = xxhash_rust::xxh3::Xxh3;
216
217    fn build_hasher(&self) -> Self::Hasher {
218        xxhash_rust::xxh3::Xxh3::new()
219    }
220}
221
222#[cfg(test)]
223thread_local! {
224    static TOKENS_INDEXED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
225}
226
227/// Tokens this thread has folded into or removed from any segment index.
228///
229/// A deterministic work counter: a gate compares deltas around one write to
230/// prove the write's lexical cost does not depend on the rows beside it.
231#[cfg(test)]
232pub(crate) fn tokens_indexed() -> u64 {
233    TOKENS_INDEXED.with(std::cell::Cell::get)
234}
235
236#[cfg_attr(not(test), allow(unused_variables))]
237fn count_tokens_indexed(tokens: usize) {
238    #[cfg(test)]
239    TOKENS_INDEXED.with(|count| {
240        count.set(
241            count
242                .get()
243                .saturating_add(u64::try_from(tokens).unwrap_or(u64::MAX)),
244        );
245    });
246}
247
248/// One segment's postings and lengths, addressed by dense row id.
249///
250/// # Why lengths are dense arrays and not a map
251///
252/// Rows are dense and ascending by the task 07 invariant, so a
253/// `BTreeMap<(row, field), u32>` spends a pointer-chasing descent — about
254/// seventeen levels at 171,000 documents — on what an array answers with one
255/// index. The scorer performs that lookup once per scored posting, so the
256/// descent was among the largest per-posting costs in the engine.
257///
258/// # Why the term table is a hash map and not a tree
259///
260/// Accumulation looks a term up once per (document, term, field): tens of
261/// millions of probes on a real corpus, against a table with millions of
262/// entries. A B-tree descent is ~20 pointer-chasing key compares per
263/// probe; an xxh3 probe is one hash and usually one compare. The sorted
264/// view the seal needs is materialized ONCE, in [`Self::postings`],
265/// instead of being maintained on every insert.
266#[derive(Clone, Debug, Default)]
267pub struct SegmentIndex {
268    /// Term key to slot in `lists`.
269    terms: HashMap<TermKey, usize, Xxh3State>,
270    /// Posting lists, in first-seen order; `terms` holds the slots.
271    lists: Vec<PostingList>,
272    /// Per-field analyzed token counts, ascending by field id.
273    lengths: Vec<FieldLengths>,
274    row_count: u32,
275    /// Slots in `lists` whose term a replacement dropped; new terms reuse
276    /// them before growing `lists`. Each holds an empty list no key names.
277    free_slots: Vec<usize>,
278}
279
280/// Where [`SegmentIndex::accumulate`] places a row's postings.
281#[derive(Clone, Copy)]
282enum Placement {
283    /// The row is the next dense row: append, refusing any lower row.
284    Append,
285    /// The row already exists: insert at its document-id position.
286    Insert,
287}
288
289impl SegmentIndex {
290    /// Creates an empty segment index.
291    #[must_use]
292    pub fn new() -> Self {
293        Self::default()
294    }
295
296    /// Returns the number of rows.
297    #[must_use]
298    pub const fn row_count(&self) -> u32 {
299        self.row_count
300    }
301
302    /// Returns true when the segment holds no rows.
303    #[must_use]
304    pub const fn is_empty(&self) -> bool {
305        self.row_count == 0
306    }
307
308    /// Returns the posting lists, ordered by term then field.
309    ///
310    /// The sorted view is built here, once per call, rather than being
311    /// maintained on every insert; seal is its one hot caller.
312    pub fn postings(&self) -> impl Iterator<Item = (&TermKey, &PostingList)> {
313        let mut entries: Vec<(&TermKey, usize)> =
314            self.terms.iter().map(|(key, slot)| (key, *slot)).collect();
315        entries.sort_unstable_by(|left, right| left.0.cmp(right.0));
316        entries
317            .into_iter()
318            .filter_map(|(key, slot)| self.lists.get(slot).map(|list| (key, list)))
319    }
320
321    /// Returns one term's posting list within one field.
322    #[must_use]
323    pub fn posting_list(&self, term: &[u8], field: FieldId) -> Option<&PostingList> {
324        let slot = *self.terms.get(&TermKey {
325            term: term.to_vec(),
326            field,
327        })?;
328        self.lists.get(slot)
329    }
330
331    /// Returns the analyzed token count of one row's field.
332    #[must_use]
333    pub fn field_length(&self, row: u32, field: FieldId) -> u32 {
334        self.field_lengths(field)
335            .and_then(|lengths| {
336                usize::try_from(row)
337                    .ok()
338                    .and_then(|slot| lengths.get(slot).copied())
339            })
340            .unwrap_or(0)
341    }
342
343    /// Returns the fields this segment recorded lengths for, ascending.
344    pub fn fields(&self) -> impl Iterator<Item = FieldId> + '_ {
345        self.lengths.iter().map(|entry| entry.field)
346    }
347
348    /// Returns one field's dense length array, if the field was ever set.
349    ///
350    /// The slice is exactly [`Self::row_count`] long, so a caller scoring a
351    /// single unweighted field can borrow it rather than rebuild it. A
352    /// linear scan over the field table is right here: a schema has a
353    /// handful of fields, and a map would trade an index for a descent.
354    #[must_use]
355    pub fn field_lengths(&self, field: FieldId) -> Option<&[u32]> {
356        self.lengths
357            .iter()
358            .find(|entry| entry.field == field)
359            .map(|entry| entry.lengths.as_slice())
360    }
361
362    /// Returns the analyzed token count of one row across every field.
363    #[must_use]
364    pub fn document_length(&self, row: u32) -> u32 {
365        let Ok(slot) = usize::try_from(row) else {
366            return 0;
367        };
368        self.lengths
369            .iter()
370            .filter_map(|entry| entry.lengths.get(slot).copied())
371            .fold(0_u32, u32::saturating_add)
372    }
373
374    /// Returns the segment's total analyzed token count.
375    #[must_use]
376    pub fn total_tokens(&self) -> u64 {
377        self.lengths
378            .iter()
379            .flat_map(|entry| entry.lengths.iter())
380            .map(|length| u64::from(*length))
381            .sum()
382    }
383
384    pub(crate) fn resident_bytes(&self) -> usize {
385        let term_slots = self
386            .terms
387            .capacity()
388            .saturating_mul(std::mem::size_of::<(TermKey, usize)>().saturating_add(1));
389        let term_bytes = self
390            .terms
391            .keys()
392            .map(|key| key.term.capacity())
393            .fold(0_usize, usize::saturating_add);
394        let list_bytes = self
395            .lists
396            .capacity()
397            .saturating_mul(std::mem::size_of::<PostingList>())
398            .saturating_add(
399                self.lists
400                    .iter()
401                    .map(PostingList::resident_bytes)
402                    .fold(0_usize, usize::saturating_add),
403            );
404        let free_slot_bytes = self
405            .free_slots
406            .capacity()
407            .saturating_mul(std::mem::size_of::<usize>());
408        let length_bytes = self
409            .lengths
410            .capacity()
411            .saturating_mul(std::mem::size_of::<FieldLengths>())
412            .saturating_add(
413                self.lengths
414                    .iter()
415                    .map(|entry| entry.lengths.capacity().saturating_mul(4))
416                    .fold(0_usize, usize::saturating_add),
417            );
418        term_slots
419            .saturating_add(term_bytes)
420            .saturating_add(list_bytes)
421            .saturating_add(length_bytes)
422            .saturating_add(free_slot_bytes)
423    }
424
425    /// Records one row's analyzed length for one field.
426    ///
427    /// Pads the field's array with zeros for any earlier row that did not
428    /// carry the field, which is what keeps every array dense.
429    fn set_field_length(&mut self, row: u32, field: FieldId, length: u32) {
430        let Ok(slot) = usize::try_from(row) else {
431            return;
432        };
433        let position = match self.lengths.iter().position(|entry| entry.field == field) {
434            Some(position) => position,
435            None => {
436                // Fields stay ascending so the table has a stable order.
437                let position = self
438                    .lengths
439                    .iter()
440                    .position(|entry| entry.field > field)
441                    .unwrap_or(self.lengths.len());
442                self.lengths.insert(
443                    position,
444                    FieldLengths {
445                        field,
446                        lengths: Vec::new(),
447                    },
448                );
449                position
450            }
451        };
452        let Some(entry) = self.lengths.get_mut(position) else {
453            return;
454        };
455        if let Some(existing) = entry.lengths.get_mut(slot) {
456            *existing = length;
457        } else {
458            entry.lengths.resize(slot, 0);
459            entry.lengths.push(length);
460        }
461    }
462
463    /// Extends every field array to cover `rows` rows.
464    fn pad_lengths_to(&mut self, rows: usize) {
465        for entry in &mut self.lengths {
466            entry.lengths.resize(rows, 0);
467        }
468    }
469
470    /// Appends one analyzed document as the next dense row.
471    ///
472    /// # Errors
473    ///
474    /// Returns [`IndexError::Postings`] when the analyzer produces a token
475    /// stream a posting list refuses, which would be a tokenizer contract
476    /// violation rather than bad input.
477    pub fn push_document(
478        &mut self,
479        analyzer: &Analyzer,
480        document: &Document,
481    ) -> Result<u32, IndexError> {
482        let row = self.row_count;
483        for (field, text) in document.fields() {
484            let tokens = analyzer.analyze(text);
485            self.accumulate(row, field, &tokens, Placement::Append)?;
486        }
487        self.finish_row();
488        Ok(row)
489    }
490
491    /// Replaces one existing row's document in place.
492    ///
493    /// `previous` must be the document the row was indexed from. The row's
494    /// postings for every term of `previous` are removed, a term left with
495    /// no posting is dropped, the row's lengths are reset, and `replacement`
496    /// is folded in at the same row. The result answers exactly as a
497    /// segment rebuilt with the row's document swapped, and the cost is
498    /// proportional to the two documents, not to the segment.
499    ///
500    /// # Errors
501    ///
502    /// Returns [`IndexError::ReplacedRowOutOfRange`] for a row the segment
503    /// does not hold, [`IndexError::ReplacedPostingMissing`] when `previous`
504    /// is not what the row was indexed from, and [`IndexError::Postings`]
505    /// as [`Self::push_document`]. On an error the segment is partly
506    /// updated; the caller discards it.
507    pub fn replace_document(
508        &mut self,
509        analyzer: &Analyzer,
510        row: u32,
511        previous: &Document,
512        replacement: &Document,
513    ) -> Result<(), IndexError> {
514        let out_of_range = IndexError::ReplacedRowOutOfRange {
515            row,
516            row_count: self.row_count,
517        };
518        if row >= self.row_count {
519            return Err(out_of_range);
520        }
521        let slot = usize::try_from(row).map_err(|_| out_of_range)?;
522        for (field, text) in previous.fields() {
523            let tokens = analyzer.analyze(text);
524            count_tokens_indexed(tokens.len());
525            let mut terms: Vec<&str> = tokens.iter().map(|token| token.term.as_str()).collect();
526            terms.sort_unstable();
527            terms.dedup();
528            let mut probe = TermKey {
529                term: Vec::new(),
530                field,
531            };
532            for term in terms {
533                probe.term.clear();
534                probe.term.extend_from_slice(term.as_bytes());
535                let missing = IndexError::ReplacedPostingMissing { row };
536                let list_slot = self.terms.get(&probe).copied().ok_or(missing.clone())?;
537                let list = self.lists.get_mut(list_slot).ok_or(missing.clone())?;
538                if !list.remove(row) {
539                    return Err(missing);
540                }
541                if list.is_empty() {
542                    *list = PostingList::new();
543                    self.terms.remove(&probe);
544                    self.free_slots.push(list_slot);
545                }
546            }
547        }
548        for entry in &mut self.lengths {
549            if let Some(length) = entry.lengths.get_mut(slot) {
550                *length = 0;
551            }
552        }
553        for (field, text) in replacement.fields() {
554            let tokens = analyzer.analyze(text);
555            self.accumulate(row, field, &tokens, Placement::Insert)?;
556        }
557        // A field new to the segment must still cover every row.
558        self.pad_lengths_to(usize::try_from(self.row_count).unwrap_or(usize::MAX));
559        Ok(())
560    }
561
562    /// Appends many documents, analyzing them in parallel.
563    ///
564    /// The result is identical to pushing the same documents one at a time
565    /// in slice order: analysis is independent per document and runs on
566    /// explicit scoped threads, while accumulation happens strictly in row
567    /// order on the calling thread — so nothing about the outcome depends
568    /// on scheduling, and the single-writer invariant is untouched.
569    ///
570    /// `threads` is the caller's policy; one or fewer runs sequentially.
571    /// Analysis is batched so the in-flight token working set stays
572    /// proportional to the batch, never to the corpus.
573    ///
574    /// # Errors
575    ///
576    /// As [`Self::push_document`]. On an error, documents before the
577    /// failing one remain indexed, exactly as a sequential loop would
578    /// leave them.
579    pub fn push_documents(
580        &mut self,
581        analyzer: &Analyzer,
582        documents: &[Document],
583        threads: usize,
584    ) -> Result<(), IndexError> {
585        let workers = threads.max(1).min(documents.len().max(1));
586        if workers <= 1 {
587            for document in documents {
588                self.push_document(analyzer, document)?;
589            }
590            return Ok(());
591        }
592        let batch_size = workers.saturating_mul(32).max(1);
593        for batch in documents.chunks(batch_size) {
594            let mut analyzed: Vec<Vec<(FieldId, Vec<Token>)>> = Vec::new();
595            analyzed.resize_with(batch.len(), Vec::new);
596            let stride = batch.len().div_ceil(workers);
597            std::thread::scope(|scope| {
598                let mut rest = analyzed.as_mut_slice();
599                let mut offset = 0_usize;
600                while !rest.is_empty() {
601                    let take = stride.min(rest.len());
602                    let (mine, tail) = rest.split_at_mut(take);
603                    rest = tail;
604                    let end = offset.saturating_add(take);
605                    let docs = batch.get(offset..end).unwrap_or(&[]);
606                    offset = end;
607                    scope.spawn(move || {
608                        for (slot, document) in mine.iter_mut().zip(docs) {
609                            *slot = document
610                                .fields()
611                                .map(|(field, text)| (field, analyzer.analyze(text)))
612                                .collect();
613                        }
614                    });
615                }
616            });
617            for fields in &analyzed {
618                let row = self.row_count;
619                for (field, tokens) in fields {
620                    self.accumulate(row, *field, tokens, Placement::Append)?;
621                }
622                self.finish_row();
623            }
624        }
625        Ok(())
626    }
627
628    /// Folds one field's analyzed tokens into the postings for one row.
629    fn accumulate(
630        &mut self,
631        row: u32,
632        field: FieldId,
633        tokens: &[Token],
634        placement: Placement,
635    ) -> Result<(), IndexError> {
636        count_tokens_indexed(tokens.len());
637        // Position count is the analyzed length: stacked variants share
638        // a position and must not inflate the document length, or avgdl
639        // stops matching the unit being scored.
640        let length = tokens
641            .iter()
642            .map(|token| token.position)
643            .max()
644            .map_or(0, |highest| highest.saturating_add(1));
645        self.set_field_length(row, field, length);
646
647        // Sort-based grouping over borrowed terms replaces the per-document
648        // `BTreeMap<Vec<u8>, Vec<u32>>`: no map to build and tear down per
649        // document, no allocation per (document, term). `str` compares
650        // byte-wise, so groups arrive in exactly the order the map's keys
651        // did, and a group's positions arrive already sorted.
652        let mut scratch: Vec<(&str, u32)> = tokens
653            .iter()
654            .map(|token| (token.term.as_str(), token.position))
655            .collect();
656        scratch.sort_unstable();
657
658        // One reusable probe key per field: lookups borrow it, and only a
659        // term new to the segment pays an owned copy.
660        let mut probe = TermKey {
661            term: Vec::new(),
662            field,
663        };
664        let mut start = 0_usize;
665        while let Some((term, _)) = scratch.get(start).copied() {
666            let mut end = start.saturating_add(1);
667            while scratch.get(end).is_some_and(|(next, _)| *next == term) {
668                end = end.saturating_add(1);
669            }
670            let group = scratch.get(start..end).unwrap_or(&[]);
671            let mut positions: Vec<u32> = Vec::with_capacity(group.len());
672            for (_, position) in group {
673                if positions.last() != Some(position) {
674                    positions.push(*position);
675                }
676            }
677            let tf = u32::try_from(positions.len()).unwrap_or(u32::MAX);
678            probe.term.clear();
679            probe.term.extend_from_slice(term.as_bytes());
680            let posting = Posting {
681                docid: row,
682                tf,
683                positions,
684            };
685            match self.terms.get(&probe).copied() {
686                Some(slot) => {
687                    // The map only ever hands out slots minted below, so
688                    // the lookup cannot miss; the `if let` is the panic-free
689                    // spelling of that impossibility.
690                    if let Some(list) = self.lists.get_mut(slot) {
691                        match placement {
692                            Placement::Append => list.push(posting)?,
693                            Placement::Insert => list.insert(posting)?,
694                        }
695                    }
696                }
697                None => {
698                    let mut list = PostingList::new();
699                    list.push(posting)?;
700                    let slot = if let Some(slot) = self.free_slots.pop()
701                        && let Some(free) = self.lists.get_mut(slot)
702                    {
703                        *free = list;
704                        slot
705                    } else {
706                        let slot = self.lists.len();
707                        self.lists.push(list);
708                        slot
709                    };
710                    self.terms.insert(
711                        TermKey {
712                            term: probe.term.clone(),
713                            field,
714                        },
715                        slot,
716                    );
717                }
718            }
719            start = end;
720        }
721        Ok(())
722    }
723
724    /// Seals the row: every field array is padded to cover it.
725    fn finish_row(&mut self) {
726        self.row_count = self.row_count.saturating_add(1);
727        self.pad_lengths_to(usize::try_from(self.row_count).unwrap_or(usize::MAX));
728    }
729}
730
731/// The store-wide lexical index across every segment.
732///
733/// # Segments here are SEALED
734///
735/// [`push_segment`](Self::push_segment) encodes the active segment into the
736/// persisted posting layout and keeps only that. There is therefore exactly
737/// one scoring path in the engine — over bytes — rather than an in-memory
738/// one that ships and a persisted one that is only ever tested.
739#[derive(Clone, Debug, Default)]
740pub struct LexicalIndex {
741    segments: Vec<Arc<SealedSegment>>,
742    statistics: Vec<Arc<LiveSegmentStatistics>>,
743    has_frequency_cache: bool,
744}
745
746#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
747struct LiveSegmentCounters {
748    documents: u64,
749    tokens: u64,
750}
751
752/// Validated live membership and counters, independent of source ordinal.
753#[derive(Clone, Debug)]
754pub(crate) struct LiveSegmentStatistics {
755    pub(crate) rows: DocBitmap,
756    counters: LiveSegmentCounters,
757    pub(crate) frequency_cache: Option<Arc<super::live_df::LiveFrequencyCache>>,
758}
759
760impl LiveSegmentStatistics {
761    pub(crate) fn build_controlled<E: From<IndexError>>(
762        ordinal: usize,
763        segment: &SealedSegment,
764        rows: &DocBitmap,
765        work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
766    ) -> Result<Self, E> {
767        work.check_now()?;
768        let value = Self {
769            counters: live_segment_counters_controlled(ordinal, segment, rows, work)?,
770            rows: rows.clone(),
771            frequency_cache: None,
772        };
773        work.check_now()?;
774        Ok(value)
775    }
776
777    pub(crate) fn build(
778        ordinal: usize,
779        segment: &SealedSegment,
780        rows: &DocBitmap,
781    ) -> Result<Self, IndexError> {
782        Self::build_controlled(
783            ordinal,
784            segment,
785            rows,
786            &mut super::control::WorkCheck::new(|| Ok(())),
787        )
788    }
789
790    pub(crate) fn allocation_bytes(rows: &DocBitmap) -> Option<usize> {
791        std::mem::size_of::<Self>()
792            .checked_add(2 * std::mem::size_of::<usize>())?
793            .checked_add(rows.resident_bytes()?)
794    }
795}
796
797impl LexicalIndex {
798    /// Creates an empty index.
799    #[must_use]
800    pub fn new() -> Self {
801        Self::default()
802    }
803
804    /// Seals one active segment and appends it.
805    ///
806    /// # Errors
807    ///
808    /// Returns [`IndexError::Postings`] when the encoder rejects the
809    /// segment's geometry or refuses to read back what it just wrote. Both
810    /// are broken-writer conditions, and they surface here rather than
811    /// becoming a decode fault inside a later query.
812    pub fn push_segment(&mut self, segment: SegmentIndex) -> Result<(), IndexError> {
813        self.push_sealed(SealedSegment::seal(&segment)?);
814        Ok(())
815    }
816
817    /// Appends an already-sealed segment.
818    pub fn push_sealed(&mut self, segment: SealedSegment) {
819        self.push_shared(Arc::new(segment));
820    }
821
822    fn push_shared(&mut self, segment: Arc<SealedSegment>) {
823        self.statistics.push(Arc::new(LiveSegmentStatistics {
824            rows: DocBitmap::full(segment.row_count()),
825            frequency_cache: None,
826            counters: LiveSegmentCounters {
827                documents: u64::from(segment.row_count()),
828                tokens: segment.total_tokens(),
829            },
830        }));
831        self.segments.push(segment);
832    }
833
834    /// Appends an already-sealed segment with exact live-row counters.
835    ///
836    /// The row set is the segment tombstone state's live bitmap, not a query
837    /// filter. Capturing it here keeps BM25 corpus statistics independent of
838    /// later metadata allow-lists while avoiding any posting-list rewrite.
839    ///
840    /// # Errors
841    ///
842    /// Returns [`IndexError::LiveRowOutOfRange`] when the tombstone state
843    /// names a row outside the sealed segment, or
844    /// [`IndexError::LiveLengthMissing`] when a sealed length counter violates
845    /// its dense-row invariant.
846    pub fn push_sealed_with_live_rows(
847        &mut self,
848        segment: SealedSegment,
849        live_rows: &DocBitmap,
850    ) -> Result<(), IndexError> {
851        self.push_shared_with_live_rows(Arc::new(segment), live_rows)
852    }
853
854    pub(crate) fn push_shared_with_live_rows(
855        &mut self,
856        segment: Arc<SealedSegment>,
857        live_rows: &DocBitmap,
858    ) -> Result<(), IndexError> {
859        let statistics = Arc::new(LiveSegmentStatistics::build(
860            self.segments.len(),
861            &segment,
862            live_rows,
863        )?);
864        self.push_shared_with_statistics(segment, statistics);
865        Ok(())
866    }
867
868    /// Only a validated contribution can enter this path; callers cannot supply
869    /// independent counters or change the membership behind the shared value.
870    pub(crate) fn push_shared_with_statistics(
871        &mut self,
872        segment: Arc<SealedSegment>,
873        statistics: Arc<LiveSegmentStatistics>,
874    ) {
875        self.has_frequency_cache |= statistics.frequency_cache.is_some();
876        self.segments.push(segment);
877        self.statistics.push(statistics);
878    }
879
880    /// Returns the segments in seal order.
881    #[must_use]
882    pub fn segments(&self) -> &[Arc<SealedSegment>] {
883        &self.segments
884    }
885
886    /// Bytes for the index spines. Cached contributions own and account their
887    /// shared membership/counters separately for the full Arc lifetime.
888    pub(crate) fn resident_spine_bytes(&self) -> Option<usize> {
889        self.segments
890            .capacity()
891            .checked_mul(std::mem::size_of::<Arc<SealedSegment>>())?
892            .checked_add(
893                self.statistics
894                    .capacity()
895                    .checked_mul(std::mem::size_of::<Arc<LiveSegmentStatistics>>())?,
896            )
897    }
898
899    /// Iterates the analyzed vocabulary of every segment.
900    ///
901    /// Terms may repeat across segments; callers that expand a structured
902    /// query must impose their own deterministic de-duplication.
903    pub fn terms(&self) -> impl Iterator<Item = &[u8]> {
904        self.segments.iter().flat_map(|segment| segment.terms())
905    }
906
907    pub(crate) fn vocabulary_terms(&self) -> impl Iterator<Item = (&[u8], FieldId)> + Clone {
908        self.segments
909            .iter()
910            .flat_map(|segment| segment.vocabulary_terms())
911    }
912
913    /// Returns the store-wide document count.
914    #[must_use]
915    pub fn document_count(&self) -> u64 {
916        self.statistics
917            .iter()
918            .map(|statistics| statistics.counters.documents)
919            .fold(0_u64, u64::saturating_add)
920    }
921
922    /// Returns the store-wide analyzed token count.
923    #[must_use]
924    pub fn total_tokens(&self) -> u64 {
925        self.statistics
926            .iter()
927            .map(|statistics| statistics.counters.tokens)
928            .fold(0_u64, u64::saturating_add)
929    }
930
931    /// Returns the store-wide corpus statistics.
932    ///
933    /// This is the ONLY source of `N` and `avgdl` for scoring. There is
934    /// deliberately no per-segment equivalent.
935    ///
936    /// # Errors
937    ///
938    /// Returns [`IndexError::Stats`] when the index holds no documents or
939    /// no tokens, because `avgdl` is undefined then.
940    pub fn corpus_stats(&self) -> Result<CorpusStats, IndexError> {
941        #[cfg(any(test, feature = "test-seams"))]
942        super::preparation_observer::corpus_statistics();
943        Ok(CorpusStats::new(
944            self.document_count(),
945            self.total_tokens(),
946        )?)
947    }
948
949    /// Returns the store-wide document frequency of one term.
950    ///
951    /// Summed across every segment and every requested field. A document
952    /// containing the term in two fields counts once, which is what makes
953    /// this a *document* frequency rather than a posting count. Segments
954    /// without tombstones answer from their sealed dictionary.
955    /// Segments with tombstones decode document identifiers and count only
956    /// rows in the same live bitmap used for `N` and `avgdl`.
957    #[must_use]
958    pub fn document_frequency(&self, term: &[u8], fields: &[FieldId]) -> u32 {
959        #[cfg(any(test, feature = "test-seams"))]
960        super::preparation_observer::frequency();
961        self.segments
962            .iter()
963            .zip(&self.statistics)
964            .map(|(segment, statistics)| {
965                let live_rows = &statistics.rows;
966                if live_rows.cardinality() == u64::from(segment.row_count()) {
967                    segment.document_frequency(term, fields)
968                } else {
969                    segment.live_document_frequency(term, fields, live_rows)
970                }
971            })
972            .fold(0_u32, u32::saturating_add)
973    }
974
975    /// Scoring can report cache synchronization errors without changing the
976    /// public infallible, uncached statistics inspection method. Callers pass
977    /// the canonical field list from FieldWeights.
978    #[inline]
979    pub(crate) fn prepared_document_frequency(
980        &self,
981        term: &[u8],
982        fields: &[FieldId],
983    ) -> Result<u32, IndexError> {
984        // Preserve the original dictionary/statistics loop when no contribution
985        // owns a cache. Cache synchronization and fallible accumulation belong
986        // only to snapshots that can actually use them.
987        if !self.has_frequency_cache {
988            return Ok(self.document_frequency(term, fields));
989        }
990        self.cached_document_frequency(term, fields)
991    }
992
993    pub(crate) fn prepared_document_frequency_controlled<E>(
994        &self,
995        term: &[u8],
996        fields: &[FieldId],
997        mut check: impl FnMut() -> Result<(), E>,
998    ) -> Result<u32, super::search::ControlledSearchError<E>> {
999        use super::search::ControlledSearchError;
1000        #[cfg(any(test, feature = "test-seams"))]
1001        super::preparation_observer::frequency();
1002        check().map_err(ControlledSearchError::Control)?;
1003        let mut total = 0_u32;
1004        for (segment, statistics) in self.segments.iter().zip(&self.statistics) {
1005            let live = &statistics.rows;
1006            let frequency = if live.cardinality() == u64::from(segment.row_count()) {
1007                segment
1008                    .document_frequency_controlled(term, fields, &mut check)
1009                    .map_err(ControlledSearchError::Control)?
1010            } else if let Some(cache) = &statistics.frequency_cache {
1011                cache.frequency_controlled(segment, live, term, fields, &mut check)?
1012            } else {
1013                segment
1014                    .live_document_frequency_controlled(term, fields, live, &mut check)
1015                    .map_err(ControlledSearchError::Control)?
1016            };
1017            total = total.saturating_add(frequency);
1018        }
1019        check().map_err(ControlledSearchError::Control)?;
1020        Ok(total)
1021    }
1022
1023    fn cached_document_frequency(
1024        &self,
1025        term: &[u8],
1026        fields: &[FieldId],
1027    ) -> Result<u32, IndexError> {
1028        #[cfg(any(test, feature = "test-seams"))]
1029        super::preparation_observer::frequency();
1030        self.segments.iter().zip(&self.statistics).try_fold(
1031            0_u32,
1032            |total, (segment, statistics)| {
1033                let live = &statistics.rows;
1034                let frequency = if live.cardinality() == u64::from(segment.row_count()) {
1035                    segment.document_frequency(term, fields)
1036                } else if let Some(cache) = &statistics.frequency_cache {
1037                    cache.frequency(segment, live, term, fields)?
1038                } else {
1039                    segment.live_document_frequency(term, fields, live)
1040                };
1041                Ok(total.saturating_add(frequency))
1042            },
1043        )
1044    }
1045}
1046
1047fn live_segment_counters_controlled<E: From<IndexError>>(
1048    ordinal: usize,
1049    segment: &SealedSegment,
1050    live_rows: &DocBitmap,
1051    work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
1052) -> Result<LiveSegmentCounters, E> {
1053    work.check_now()?;
1054    if let Some(row) = live_rows.first_at_or_after(segment.row_count()) {
1055        return Err(IndexError::LiveRowOutOfRange {
1056            segment: ordinal,
1057            row,
1058            row_count: segment.row_count(),
1059        }
1060        .into());
1061    }
1062    let tokens =
1063        live_rows.iter().try_fold(0_u64, |total, row| {
1064            work.step()?;
1065            #[cfg(any(test, feature = "test-seams"))]
1066            super::preparation_observer::live_statistics_row();
1067            let slot = usize::try_from(row).map_err(|_| IndexError::LiveLengthMissing {
1068                segment: ordinal,
1069                row,
1070            })?;
1071            let length = segment.total_lengths().get(slot).copied().ok_or(
1072                IndexError::LiveLengthMissing {
1073                    segment: ordinal,
1074                    row,
1075                },
1076            )?;
1077            Ok::<u64, E>(total.saturating_add(u64::from(length)))
1078        })?;
1079    work.check_now()?;
1080    Ok(LiveSegmentCounters {
1081        documents: live_rows.cardinality(),
1082        tokens,
1083    })
1084}
1085
1086#[cfg(test)]
1087#[allow(
1088    clippy::expect_used,
1089    clippy::indexing_slicing,
1090    clippy::panic,
1091    clippy::unwrap_used
1092)]
1093mod tests {
1094
1095    #[test]
1096    fn parallel_analysis_is_identical_to_the_sequential_loop() {
1097        // push_documents claims scheduling independence; this holds it to
1098        // the sequential oracle exactly -- every posting, every position,
1099        // every length array -- across thread counts and a document count
1100        // chosen to leave a ragged final batch.
1101        let analyzer = analyzer();
1102        let documents: Vec<Document> = (0..97)
1103            .map(|index| {
1104                let mut document = Document::new();
1105                document.set(
1106                    FieldId(0),
1107                    &format!("alpha beta{} gamma-{index} 401k", index % 7),
1108                );
1109                document.set(FieldId(1), &format!("delta epsilon{} the quick", index % 5));
1110                document
1111            })
1112            .collect();
1113        let mut sequential = SegmentIndex::new();
1114        for document in &documents {
1115            sequential
1116                .push_document(&analyzer, document)
1117                .expect("indexable");
1118        }
1119        for threads in [1_usize, 3, 8] {
1120            let mut parallel = SegmentIndex::new();
1121            parallel
1122                .push_documents(&analyzer, &documents, threads)
1123                .expect("indexable");
1124            assert_eq!(parallel.row_count(), sequential.row_count());
1125            let ours: Vec<_> = parallel.postings().collect();
1126            let oracle: Vec<_> = sequential.postings().collect();
1127            assert_eq!(
1128                ours.len(),
1129                oracle.len(),
1130                "term count diverged at {threads} threads"
1131            );
1132            for ((key, list), (oracle_key, oracle_list)) in ours.iter().zip(&oracle) {
1133                assert_eq!(key, oracle_key);
1134                assert_eq!(
1135                    list.postings(),
1136                    oracle_list.postings(),
1137                    "postings diverged for {:?} at {threads} threads",
1138                    String::from_utf8_lossy(&key.term)
1139                );
1140            }
1141            for field in [FieldId(0), FieldId(1)] {
1142                assert_eq!(
1143                    parallel.field_lengths(field),
1144                    sequential.field_lengths(field),
1145                    "lengths diverged at {threads} threads"
1146                );
1147            }
1148        }
1149    }
1150
1151    use super::*;
1152    use crate::fts::tokenizer::{Profile, TokenizerConfig};
1153
1154    #[test]
1155    fn astra_18_live_statistics_cancel_inside_row_walk() {
1156        #[derive(Debug)]
1157        enum Error {
1158            Index(IndexError),
1159            Cancelled,
1160        }
1161        impl From<IndexError> for Error {
1162            fn from(error: IndexError) -> Self {
1163                Self::Index(error)
1164            }
1165        }
1166        let segment = SealedSegment::seal(&segment_of(&analyzer(), &vec!["alpha beta"; 4_097]))
1167            .expect("sealed row fixture");
1168        let live = DocBitmap::from_ids(0..4_096);
1169        super::super::preparation_observer::begin();
1170        let mut work = super::super::control::WorkCheck::new(|| {
1171            if super::super::preparation_observer::live_statistics_rows() >= 64 {
1172                Err(Error::Cancelled)
1173            } else {
1174                Ok(())
1175            }
1176        });
1177        let result = LiveSegmentStatistics::build_controlled(7, &segment, &live, &mut work);
1178        let visited = super::super::preparation_observer::live_statistics_rows();
1179        super::super::preparation_observer::take();
1180        println!("live statistics rows={visited}");
1181        assert!(matches!(result, Err(Error::Cancelled)));
1182        assert!((64..=128).contains(&visited));
1183        let mut clean = super::super::control::WorkCheck::new(|| Ok::<(), Error>(()));
1184        let value = LiveSegmentStatistics::build_controlled(7, &segment, &live, &mut clean)
1185            .expect("fresh control");
1186        assert_eq!(value.counters.documents, 4_096);
1187        assert_eq!(value.counters.tokens, 8_192);
1188        assert_eq!(value.rows, live);
1189        assert!(value.frequency_cache.is_none());
1190        assert!(matches!(
1191            LiveSegmentStatistics::build_controlled(
1192                7,
1193                &segment,
1194                &DocBitmap::from_ids([4_097]),
1195                &mut clean
1196            ),
1197            Err(Error::Index(IndexError::LiveRowOutOfRange {
1198                segment: 7,
1199                row: 4_097,
1200                row_count: 4_097
1201            }))
1202        ));
1203    }
1204
1205    fn analyzer() -> Analyzer {
1206        Analyzer::new(TokenizerConfig::text_default()).expect("valid config")
1207    }
1208
1209    #[test]
1210    fn replace_document_refuses_a_row_or_previous_text_the_index_does_not_hold() {
1211        let analyzer = analyzer();
1212        let mut index = SegmentIndex::new();
1213        index
1214            .push_document(&analyzer, &Document::with_text("alpha beta"))
1215            .expect("indexable");
1216        let replacement = Document::with_text("gamma");
1217
1218        assert_eq!(
1219            index.replace_document(&analyzer, 1, &Document::with_text("alpha"), &replacement),
1220            Err(IndexError::ReplacedRowOutOfRange {
1221                row: 1,
1222                row_count: 1
1223            })
1224        );
1225        assert_eq!(
1226            index.replace_document(&analyzer, 0, &Document::with_text("delta"), &replacement),
1227            Err(IndexError::ReplacedPostingMissing { row: 0 })
1228        );
1229        // A term the index holds, but not for this row.
1230        index
1231            .push_document(&analyzer, &Document::with_text("epsilon"))
1232            .expect("indexable");
1233        assert_eq!(
1234            index.replace_document(&analyzer, 0, &Document::with_text("epsilon"), &replacement),
1235            Err(IndexError::ReplacedPostingMissing { row: 0 })
1236        );
1237        assert!(
1238            IndexError::ReplacedPostingMissing { row: 0 }
1239                .to_string()
1240                .contains("row 0")
1241        );
1242        assert!(
1243            IndexError::ReplacedRowOutOfRange {
1244                row: 1,
1245                row_count: 1
1246            }
1247            .to_string()
1248            .contains("1 rows")
1249        );
1250    }
1251
1252    #[test]
1253    fn replace_document_reuses_a_dropped_term_slot_and_pads_a_new_field() {
1254        let analyzer = analyzer();
1255        let mut index = SegmentIndex::new();
1256        for text in ["alpha", "beta", "alpha delta"] {
1257            index
1258                .push_document(&analyzer, &Document::with_text(text))
1259                .expect("indexable");
1260        }
1261        let mut replacement = Document::with_text("alpha");
1262        replacement.set(FieldId(1), "gamma");
1263        index
1264            .replace_document(&analyzer, 1, &Document::with_text("beta"), &replacement)
1265            .expect("replace row 1");
1266        // "gamma" took the slot "beta" freed; no list was appended.
1267        assert_eq!(index.lists.len(), 3);
1268        assert!(index.posting_list(b"beta", DEFAULT_FIELD).is_none());
1269        assert!(index.free_slots.is_empty());
1270        assert_eq!(
1271            index.posting_list(b"alpha", DEFAULT_FIELD).map(|list| list
1272                .postings()
1273                .iter()
1274                .map(|p| p.docid)
1275                .collect::<Vec<_>>()),
1276            Some(vec![0, 1, 2])
1277        );
1278        assert_eq!(index.field_lengths(FieldId(1)), Some(&[0, 1, 0][..]));
1279        index
1280            .replace_document(
1281                &analyzer,
1282                2,
1283                &Document::with_text("alpha delta"),
1284                &Document::new(),
1285            )
1286            .expect("replace row 2");
1287        assert!(index.posting_list(b"delta", DEFAULT_FIELD).is_none());
1288        assert_eq!(index.free_slots.len(), 1);
1289        assert_eq!(index.total_tokens(), 3);
1290    }
1291
1292    #[test]
1293    fn posting_list_insert_refuses_a_duplicate_row() {
1294        let mut list = PostingList::new();
1295        let posting = |docid| Posting {
1296            docid,
1297            tf: 1,
1298            positions: vec![0],
1299        };
1300        list.push(posting(0)).expect("append");
1301        list.push(posting(4)).expect("append");
1302        list.insert(posting(2)).expect("insert between");
1303        assert_eq!(
1304            list.insert(posting(2)),
1305            Err(PostingsError::DocidsNotAscending { docid: 2 })
1306        );
1307        assert_eq!(
1308            list.insert(Posting {
1309                docid: 3,
1310                tf: 0,
1311                positions: Vec::new()
1312            }),
1313            Err(PostingsError::ZeroTermFrequency)
1314        );
1315        assert_eq!(
1316            list.insert(Posting {
1317                docid: 3,
1318                tf: 2,
1319                positions: vec![1, 1]
1320            }),
1321            Err(PostingsError::PositionsNotAscending { position: 1 })
1322        );
1323        assert!(list.remove(2));
1324        assert!(!list.remove(2));
1325        assert_eq!(
1326            list.postings().iter().map(|p| p.docid).collect::<Vec<_>>(),
1327            vec![0, 4]
1328        );
1329    }
1330
1331    fn code_analyzer() -> Analyzer {
1332        Analyzer::new(Profile::Code.config()).expect("valid config")
1333    }
1334
1335    fn segment_of(analyzer: &Analyzer, texts: &[&str]) -> SegmentIndex {
1336        let mut segment = SegmentIndex::new();
1337        for text in texts {
1338            segment
1339                .push_document(analyzer, &Document::with_text(text))
1340                .expect("indexable");
1341        }
1342        segment
1343    }
1344
1345    #[test]
1346    fn rows_are_dense_and_ascending() {
1347        let analyzer = analyzer();
1348        let mut segment = SegmentIndex::new();
1349        for (expected, text) in ["alpha", "beta", "gamma"].into_iter().enumerate() {
1350            let row = segment
1351                .push_document(&analyzer, &Document::with_text(text))
1352                .expect("indexable");
1353            assert_eq!(row, u32::try_from(expected).expect("small"));
1354        }
1355        assert_eq!(segment.row_count(), 3);
1356    }
1357
1358    #[test]
1359    fn document_frequency_is_summed_across_segments_not_per_segment() {
1360        let analyzer = code_analyzer();
1361        let mut index = LexicalIndex::new();
1362        index
1363            .push_segment(segment_of(&analyzer, &["engine", "engine"]))
1364            .expect("seals");
1365        index
1366            .push_segment(segment_of(&analyzer, &["engine", "other"]))
1367            .expect("seals");
1368        assert_eq!(index.document_count(), 4);
1369        // Three of the four documents contain "engine", across two segments.
1370        assert_eq!(index.document_frequency(b"engine", &[DEFAULT_FIELD]), 3);
1371    }
1372
1373    #[test]
1374    fn a_term_in_two_fields_of_one_document_counts_once() {
1375        let analyzer = code_analyzer();
1376        let mut segment = SegmentIndex::new();
1377        let mut document = Document::new();
1378        document.set(FieldId(0), "engine");
1379        document.set(FieldId(1), "engine");
1380        segment
1381            .push_document(&analyzer, &document)
1382            .expect("indexable");
1383        let mut index = LexicalIndex::new();
1384        index.push_segment(segment).expect("seals");
1385        assert_eq!(
1386            index.document_frequency(b"engine", &[FieldId(0), FieldId(1)]),
1387            1
1388        );
1389    }
1390
1391    #[test]
1392    fn corpus_stats_span_every_segment() {
1393        let analyzer = code_analyzer();
1394        let mut index = LexicalIndex::new();
1395        index
1396            .push_segment(segment_of(&analyzer, &["a b c"]))
1397            .expect("seals");
1398        index
1399            .push_segment(segment_of(&analyzer, &["d e f g h"]))
1400            .expect("seals");
1401        let stats = index.corpus_stats().expect("two documents");
1402        assert_eq!(stats.document_count(), 2);
1403        assert_eq!(stats.total_tokens(), 8);
1404        assert!((stats.average_document_length() - 4.0).abs() < 1e-12);
1405    }
1406
1407    #[test]
1408    fn live_row_counters_and_document_frequency_exclude_tombstones() {
1409        let analyzer = code_analyzer();
1410        let sealed = SealedSegment::seal(&segment_of(
1411            &analyzer,
1412            &["engine padding padding", "engine"],
1413        ))
1414        .expect("seal lexical segment");
1415        let live_rows = DocBitmap::from_ids([1]);
1416        let mut index = LexicalIndex::new();
1417        index
1418            .push_sealed_with_live_rows(sealed, &live_rows)
1419            .expect("valid live rows");
1420
1421        let stats = index.corpus_stats().expect("one live document");
1422        assert_eq!(stats.document_count(), 1);
1423        assert_eq!(stats.total_tokens(), 1);
1424        assert_eq!(
1425            index.document_frequency(b"engine", &[DEFAULT_FIELD]),
1426            1,
1427            "document frequency must use the same live rows as corpus stats"
1428        );
1429    }
1430
1431    #[test]
1432    fn an_empty_index_refuses_to_produce_statistics() {
1433        let index = LexicalIndex::new();
1434        assert!(index.corpus_stats().is_err());
1435    }
1436
1437    #[test]
1438    fn document_length_counts_positions_not_tokens() {
1439        // Stacked variants share a position, so an identifier that emits
1440        // three tokens at one position must not count as three tokens of
1441        // length, or avgdl stops matching the unit being scored.
1442        let analyzer = code_analyzer();
1443        let mut segment = SegmentIndex::new();
1444        segment
1445            .push_document(&analyzer, &Document::with_text("put_if_match"))
1446            .expect("indexable");
1447        // Positions 0, 1, 2 for put / if / match.
1448        assert_eq!(segment.document_length(0), 3);
1449    }
1450
1451    #[test]
1452    fn field_lengths_are_tracked_separately_and_summed() {
1453        let analyzer = code_analyzer();
1454        let mut segment = SegmentIndex::new();
1455        let mut document = Document::new();
1456        document.set(FieldId(0), "one two");
1457        document.set(FieldId(1), "three four five");
1458        segment
1459            .push_document(&analyzer, &document)
1460            .expect("indexable");
1461        assert_eq!(segment.field_length(0, FieldId(0)), 2);
1462        assert_eq!(segment.field_length(0, FieldId(1)), 3);
1463        assert_eq!(segment.document_length(0), 5);
1464        assert_eq!(segment.total_tokens(), 5);
1465        assert_eq!(segment.field_length(0, FieldId(9)), 0);
1466    }
1467
1468    #[test]
1469    fn posting_lists_carry_positions() {
1470        let analyzer = code_analyzer();
1471        let segment = segment_of(&analyzer, &["alpha beta alpha"]);
1472        let list = segment
1473            .posting_list(b"alpha", DEFAULT_FIELD)
1474            .expect("alpha present");
1475        let posting = list.postings().first().expect("one posting");
1476        assert_eq!(posting.tf, 2);
1477        assert_eq!(posting.positions, vec![0, 2]);
1478    }
1479
1480    #[test]
1481    fn an_absent_term_has_no_posting_list_and_zero_frequency() {
1482        let analyzer = code_analyzer();
1483        let segment = segment_of(&analyzer, &["alpha"]);
1484        assert!(segment.posting_list(b"omega", DEFAULT_FIELD).is_none());
1485        let mut index = LexicalIndex::new();
1486        index.push_segment(segment).expect("seals");
1487        assert_eq!(index.document_frequency(b"omega", &[DEFAULT_FIELD]), 0);
1488    }
1489
1490    #[test]
1491    fn errors_render_useful_text() {
1492        assert!(
1493            IndexError::RowsNotAscending { row: 3 }
1494                .to_string()
1495                .contains("row order")
1496        );
1497        assert!(
1498            IndexError::Stats(Bm25Error::EmptyCorpus)
1499                .to_string()
1500                .contains("statistics")
1501        );
1502        assert!(
1503            IndexError::Postings(PostingsError::ZeroTermFrequency)
1504                .to_string()
1505                .contains("postings")
1506        );
1507    }
1508}