1use 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#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
44pub struct FieldId(pub u16);
45
46pub const DEFAULT_FIELD: FieldId = FieldId(0);
48
49#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
51pub struct TermKey {
52 pub term: Vec<u8>,
54 pub field: FieldId,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum IndexError {
61 CursorSlotMissing {
63 slot: usize,
65 },
66 LiveFrequencyCache {
69 reason: &'static str,
71 },
72 Postings(PostingsError),
74 Stats(Bm25Error),
76 RowsNotAscending {
78 row: u32,
80 },
81 LiveRowOutOfRange {
83 segment: usize,
85 row: u32,
87 row_count: u32,
89 },
90 LiveLengthMissing {
92 segment: usize,
94 row: u32,
96 },
97 ReplacedRowOutOfRange {
99 row: u32,
101 row_count: u32,
103 },
104 ReplacedPostingMissing {
107 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#[derive(Clone, Debug, Default, Eq, PartialEq)]
166pub struct Document {
167 fields: BTreeMap<FieldId, String>,
168}
169
170impl Document {
171 #[must_use]
173 pub fn new() -> Self {
174 Self::default()
175 }
176
177 #[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 pub fn set(&mut self, field: FieldId, text: &str) -> &mut Self {
187 self.fields.insert(field, text.to_owned());
188 self
189 }
190
191 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#[derive(Clone, Debug)]
205struct FieldLengths {
206 field: FieldId,
207 lengths: Vec<u32>,
208}
209
210#[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#[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#[derive(Clone, Debug, Default)]
267pub struct SegmentIndex {
268 terms: HashMap<TermKey, usize, Xxh3State>,
270 lists: Vec<PostingList>,
272 lengths: Vec<FieldLengths>,
274 row_count: u32,
275 free_slots: Vec<usize>,
278}
279
280#[derive(Clone, Copy)]
282enum Placement {
283 Append,
285 Insert,
287}
288
289impl SegmentIndex {
290 #[must_use]
292 pub fn new() -> Self {
293 Self::default()
294 }
295
296 #[must_use]
298 pub const fn row_count(&self) -> u32 {
299 self.row_count
300 }
301
302 #[must_use]
304 pub const fn is_empty(&self) -> bool {
305 self.row_count == 0
306 }
307
308 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 #[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 #[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 pub fn fields(&self) -> impl Iterator<Item = FieldId> + '_ {
345 self.lengths.iter().map(|entry| entry.field)
346 }
347
348 #[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 #[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 #[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 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 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 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 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 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 self.pad_lengths_to(usize::try_from(self.row_count).unwrap_or(usize::MAX));
559 Ok(())
560 }
561
562 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 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 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 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 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 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 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#[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#[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 #[must_use]
800 pub fn new() -> Self {
801 Self::default()
802 }
803
804 pub fn push_segment(&mut self, segment: SegmentIndex) -> Result<(), IndexError> {
813 self.push_sealed(SealedSegment::seal(&segment)?);
814 Ok(())
815 }
816
817 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 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 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 #[must_use]
882 pub fn segments(&self) -> &[Arc<SealedSegment>] {
883 &self.segments
884 }
885
886 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 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 #[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 #[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 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 #[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 #[inline]
979 pub(crate) fn prepared_document_frequency(
980 &self,
981 term: &[u8],
982 fields: &[FieldId],
983 ) -> Result<u32, IndexError> {
984 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 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 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 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 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 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 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}