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 LiveFrequencyCache {
64 reason: &'static str,
66 },
67 Postings(PostingsError),
69 Stats(Bm25Error),
71 RowsNotAscending {
73 row: u32,
75 },
76 LiveRowOutOfRange {
78 segment: usize,
80 row: u32,
82 row_count: u32,
84 },
85 LiveLengthMissing {
87 segment: usize,
89 row: u32,
91 },
92}
93
94impl std::fmt::Display for IndexError {
95 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 match self {
97 Self::LiveFrequencyCache { reason } => {
98 write!(formatter, "live document frequency cache failed: {reason}")
99 }
100 Self::Postings(error) => write!(formatter, "postings rejected: {error}"),
101 Self::Stats(error) => write!(formatter, "corpus statistics unavailable: {error}"),
102 Self::RowsNotAscending { row } => {
103 write!(formatter, "documents must arrive in row order; got {row}")
104 }
105 Self::LiveRowOutOfRange {
106 segment,
107 row,
108 row_count,
109 } => write!(
110 formatter,
111 "live lexical row {row} is outside segment {segment} row count {row_count}"
112 ),
113 Self::LiveLengthMissing { segment, row } => write!(
114 formatter,
115 "live lexical row {row} has no length counter in segment {segment}"
116 ),
117 }
118 }
119}
120
121impl std::error::Error for IndexError {}
122
123impl From<PostingsError> for IndexError {
124 fn from(error: PostingsError) -> Self {
125 Self::Postings(error)
126 }
127}
128
129impl From<Bm25Error> for IndexError {
130 fn from(error: Bm25Error) -> Self {
131 Self::Stats(error)
132 }
133}
134
135#[derive(Clone, Debug, Default, Eq, PartialEq)]
137pub struct Document {
138 fields: BTreeMap<FieldId, String>,
139}
140
141impl Document {
142 #[must_use]
144 pub fn new() -> Self {
145 Self::default()
146 }
147
148 #[must_use]
150 pub fn with_text(text: &str) -> Self {
151 let mut document = Self::new();
152 document.set(DEFAULT_FIELD, text);
153 document
154 }
155
156 pub fn set(&mut self, field: FieldId, text: &str) -> &mut Self {
158 self.fields.insert(field, text.to_owned());
159 self
160 }
161
162 pub fn fields(&self) -> impl Iterator<Item = (FieldId, &str)> {
164 self.fields
165 .iter()
166 .map(|(field, text)| (*field, text.as_str()))
167 }
168}
169
170#[derive(Clone, Debug)]
176struct FieldLengths {
177 field: FieldId,
178 lengths: Vec<u32>,
179}
180
181#[derive(Clone, Debug, Default)]
183struct Xxh3State;
184
185impl std::hash::BuildHasher for Xxh3State {
186 type Hasher = xxhash_rust::xxh3::Xxh3;
187
188 fn build_hasher(&self) -> Self::Hasher {
189 xxhash_rust::xxh3::Xxh3::new()
190 }
191}
192
193#[derive(Clone, Debug, Default)]
212pub struct SegmentIndex {
213 terms: HashMap<TermKey, usize, Xxh3State>,
215 lists: Vec<PostingList>,
217 lengths: Vec<FieldLengths>,
219 row_count: u32,
220}
221
222impl SegmentIndex {
223 #[must_use]
225 pub fn new() -> Self {
226 Self::default()
227 }
228
229 #[must_use]
231 pub const fn row_count(&self) -> u32 {
232 self.row_count
233 }
234
235 #[must_use]
237 pub const fn is_empty(&self) -> bool {
238 self.row_count == 0
239 }
240
241 pub fn postings(&self) -> impl Iterator<Item = (&TermKey, &PostingList)> {
246 let mut entries: Vec<(&TermKey, usize)> =
247 self.terms.iter().map(|(key, slot)| (key, *slot)).collect();
248 entries.sort_unstable_by(|left, right| left.0.cmp(right.0));
249 entries
250 .into_iter()
251 .filter_map(|(key, slot)| self.lists.get(slot).map(|list| (key, list)))
252 }
253
254 #[must_use]
256 pub fn posting_list(&self, term: &[u8], field: FieldId) -> Option<&PostingList> {
257 let slot = *self.terms.get(&TermKey {
258 term: term.to_vec(),
259 field,
260 })?;
261 self.lists.get(slot)
262 }
263
264 #[must_use]
266 pub fn field_length(&self, row: u32, field: FieldId) -> u32 {
267 self.field_lengths(field)
268 .and_then(|lengths| {
269 usize::try_from(row)
270 .ok()
271 .and_then(|slot| lengths.get(slot).copied())
272 })
273 .unwrap_or(0)
274 }
275
276 pub fn fields(&self) -> impl Iterator<Item = FieldId> + '_ {
278 self.lengths.iter().map(|entry| entry.field)
279 }
280
281 #[must_use]
288 pub fn field_lengths(&self, field: FieldId) -> Option<&[u32]> {
289 self.lengths
290 .iter()
291 .find(|entry| entry.field == field)
292 .map(|entry| entry.lengths.as_slice())
293 }
294
295 #[must_use]
297 pub fn document_length(&self, row: u32) -> u32 {
298 let Ok(slot) = usize::try_from(row) else {
299 return 0;
300 };
301 self.lengths
302 .iter()
303 .filter_map(|entry| entry.lengths.get(slot).copied())
304 .fold(0_u32, u32::saturating_add)
305 }
306
307 #[must_use]
309 pub fn total_tokens(&self) -> u64 {
310 self.lengths
311 .iter()
312 .flat_map(|entry| entry.lengths.iter())
313 .map(|length| u64::from(*length))
314 .sum()
315 }
316
317 pub(crate) fn resident_bytes(&self) -> usize {
318 let term_slots = self
319 .terms
320 .capacity()
321 .saturating_mul(std::mem::size_of::<(TermKey, usize)>().saturating_add(1));
322 let term_bytes = self
323 .terms
324 .keys()
325 .map(|key| key.term.capacity())
326 .fold(0_usize, usize::saturating_add);
327 let list_bytes = self
328 .lists
329 .capacity()
330 .saturating_mul(std::mem::size_of::<PostingList>())
331 .saturating_add(
332 self.lists
333 .iter()
334 .map(PostingList::resident_bytes)
335 .fold(0_usize, usize::saturating_add),
336 );
337 let length_bytes = self
338 .lengths
339 .capacity()
340 .saturating_mul(std::mem::size_of::<FieldLengths>())
341 .saturating_add(
342 self.lengths
343 .iter()
344 .map(|entry| entry.lengths.capacity().saturating_mul(4))
345 .fold(0_usize, usize::saturating_add),
346 );
347 term_slots
348 .saturating_add(term_bytes)
349 .saturating_add(list_bytes)
350 .saturating_add(length_bytes)
351 }
352
353 fn set_field_length(&mut self, row: u32, field: FieldId, length: u32) {
358 let Ok(slot) = usize::try_from(row) else {
359 return;
360 };
361 let position = match self.lengths.iter().position(|entry| entry.field == field) {
362 Some(position) => position,
363 None => {
364 let position = self
366 .lengths
367 .iter()
368 .position(|entry| entry.field > field)
369 .unwrap_or(self.lengths.len());
370 self.lengths.insert(
371 position,
372 FieldLengths {
373 field,
374 lengths: Vec::new(),
375 },
376 );
377 position
378 }
379 };
380 let Some(entry) = self.lengths.get_mut(position) else {
381 return;
382 };
383 entry.lengths.resize(slot, 0);
384 entry.lengths.push(length);
385 }
386
387 fn pad_lengths_to(&mut self, rows: usize) {
389 for entry in &mut self.lengths {
390 entry.lengths.resize(rows, 0);
391 }
392 }
393
394 pub fn push_document(
402 &mut self,
403 analyzer: &Analyzer,
404 document: &Document,
405 ) -> Result<u32, IndexError> {
406 let row = self.row_count;
407 for (field, text) in document.fields() {
408 let tokens = analyzer.analyze(text);
409 self.accumulate(row, field, &tokens)?;
410 }
411 self.finish_row();
412 Ok(row)
413 }
414
415 pub fn push_documents(
433 &mut self,
434 analyzer: &Analyzer,
435 documents: &[Document],
436 threads: usize,
437 ) -> Result<(), IndexError> {
438 let workers = threads.max(1).min(documents.len().max(1));
439 if workers <= 1 {
440 for document in documents {
441 self.push_document(analyzer, document)?;
442 }
443 return Ok(());
444 }
445 let batch_size = workers.saturating_mul(32).max(1);
446 for batch in documents.chunks(batch_size) {
447 let mut analyzed: Vec<Vec<(FieldId, Vec<Token>)>> = Vec::new();
448 analyzed.resize_with(batch.len(), Vec::new);
449 let stride = batch.len().div_ceil(workers);
450 std::thread::scope(|scope| {
451 let mut rest = analyzed.as_mut_slice();
452 let mut offset = 0_usize;
453 while !rest.is_empty() {
454 let take = stride.min(rest.len());
455 let (mine, tail) = rest.split_at_mut(take);
456 rest = tail;
457 let end = offset.saturating_add(take);
458 let docs = batch.get(offset..end).unwrap_or(&[]);
459 offset = end;
460 scope.spawn(move || {
461 for (slot, document) in mine.iter_mut().zip(docs) {
462 *slot = document
463 .fields()
464 .map(|(field, text)| (field, analyzer.analyze(text)))
465 .collect();
466 }
467 });
468 }
469 });
470 for fields in &analyzed {
471 let row = self.row_count;
472 for (field, tokens) in fields {
473 self.accumulate(row, *field, tokens)?;
474 }
475 self.finish_row();
476 }
477 }
478 Ok(())
479 }
480
481 fn accumulate(&mut self, row: u32, field: FieldId, tokens: &[Token]) -> Result<(), IndexError> {
483 let length = tokens
487 .iter()
488 .map(|token| token.position)
489 .max()
490 .map_or(0, |highest| highest.saturating_add(1));
491 self.set_field_length(row, field, length);
492
493 let mut scratch: Vec<(&str, u32)> = tokens
499 .iter()
500 .map(|token| (token.term.as_str(), token.position))
501 .collect();
502 scratch.sort_unstable();
503
504 let mut probe = TermKey {
507 term: Vec::new(),
508 field,
509 };
510 let mut start = 0_usize;
511 while let Some((term, _)) = scratch.get(start).copied() {
512 let mut end = start.saturating_add(1);
513 while scratch.get(end).is_some_and(|(next, _)| *next == term) {
514 end = end.saturating_add(1);
515 }
516 let group = scratch.get(start..end).unwrap_or(&[]);
517 let mut positions: Vec<u32> = Vec::with_capacity(group.len());
518 for (_, position) in group {
519 if positions.last() != Some(position) {
520 positions.push(*position);
521 }
522 }
523 let tf = u32::try_from(positions.len()).unwrap_or(u32::MAX);
524 probe.term.clear();
525 probe.term.extend_from_slice(term.as_bytes());
526 let posting = Posting {
527 docid: row,
528 tf,
529 positions,
530 };
531 match self.terms.get(&probe).copied() {
532 Some(slot) => {
533 if let Some(list) = self.lists.get_mut(slot) {
537 list.push(posting)?;
538 }
539 }
540 None => {
541 let mut list = PostingList::new();
542 list.push(posting)?;
543 let slot = self.lists.len();
544 self.lists.push(list);
545 self.terms.insert(
546 TermKey {
547 term: probe.term.clone(),
548 field,
549 },
550 slot,
551 );
552 }
553 }
554 start = end;
555 }
556 Ok(())
557 }
558
559 fn finish_row(&mut self) {
561 self.row_count = self.row_count.saturating_add(1);
562 self.pad_lengths_to(usize::try_from(self.row_count).unwrap_or(usize::MAX));
563 }
564}
565
566#[derive(Clone, Debug, Default)]
575pub struct LexicalIndex {
576 segments: Vec<Arc<SealedSegment>>,
577 statistics: Vec<Arc<LiveSegmentStatistics>>,
578 has_frequency_cache: bool,
579}
580
581#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
582struct LiveSegmentCounters {
583 documents: u64,
584 tokens: u64,
585}
586
587#[derive(Clone, Debug)]
589pub(crate) struct LiveSegmentStatistics {
590 pub(crate) rows: DocBitmap,
591 counters: LiveSegmentCounters,
592 pub(crate) frequency_cache: Option<Arc<super::live_df::LiveFrequencyCache>>,
593}
594
595impl LiveSegmentStatistics {
596 pub(crate) fn build_controlled<E: From<IndexError>>(
597 ordinal: usize,
598 segment: &SealedSegment,
599 rows: &DocBitmap,
600 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
601 ) -> Result<Self, E> {
602 work.check_now()?;
603 let value = Self {
604 counters: live_segment_counters_controlled(ordinal, segment, rows, work)?,
605 rows: rows.clone(),
606 frequency_cache: None,
607 };
608 work.check_now()?;
609 Ok(value)
610 }
611
612 pub(crate) fn build(
613 ordinal: usize,
614 segment: &SealedSegment,
615 rows: &DocBitmap,
616 ) -> Result<Self, IndexError> {
617 Self::build_controlled(
618 ordinal,
619 segment,
620 rows,
621 &mut super::control::WorkCheck::new(|| Ok(())),
622 )
623 }
624
625 pub(crate) fn allocation_bytes(rows: &DocBitmap) -> Option<usize> {
626 std::mem::size_of::<Self>()
627 .checked_add(2 * std::mem::size_of::<usize>())?
628 .checked_add(rows.resident_bytes()?)
629 }
630}
631
632impl LexicalIndex {
633 #[must_use]
635 pub fn new() -> Self {
636 Self::default()
637 }
638
639 pub fn push_segment(&mut self, segment: SegmentIndex) -> Result<(), IndexError> {
648 self.push_sealed(SealedSegment::seal(&segment)?);
649 Ok(())
650 }
651
652 pub fn push_sealed(&mut self, segment: SealedSegment) {
654 self.push_shared(Arc::new(segment));
655 }
656
657 fn push_shared(&mut self, segment: Arc<SealedSegment>) {
658 self.statistics.push(Arc::new(LiveSegmentStatistics {
659 rows: DocBitmap::full(segment.row_count()),
660 frequency_cache: None,
661 counters: LiveSegmentCounters {
662 documents: u64::from(segment.row_count()),
663 tokens: segment.total_tokens(),
664 },
665 }));
666 self.segments.push(segment);
667 }
668
669 pub fn push_sealed_with_live_rows(
682 &mut self,
683 segment: SealedSegment,
684 live_rows: &DocBitmap,
685 ) -> Result<(), IndexError> {
686 self.push_shared_with_live_rows(Arc::new(segment), live_rows)
687 }
688
689 pub(crate) fn push_shared_with_live_rows(
690 &mut self,
691 segment: Arc<SealedSegment>,
692 live_rows: &DocBitmap,
693 ) -> Result<(), IndexError> {
694 let statistics = Arc::new(LiveSegmentStatistics::build(
695 self.segments.len(),
696 &segment,
697 live_rows,
698 )?);
699 self.push_shared_with_statistics(segment, statistics);
700 Ok(())
701 }
702
703 pub(crate) fn push_shared_with_statistics(
706 &mut self,
707 segment: Arc<SealedSegment>,
708 statistics: Arc<LiveSegmentStatistics>,
709 ) {
710 self.has_frequency_cache |= statistics.frequency_cache.is_some();
711 self.segments.push(segment);
712 self.statistics.push(statistics);
713 }
714
715 #[must_use]
717 pub fn segments(&self) -> &[Arc<SealedSegment>] {
718 &self.segments
719 }
720
721 pub(crate) fn resident_spine_bytes(&self) -> Option<usize> {
724 self.segments
725 .capacity()
726 .checked_mul(std::mem::size_of::<Arc<SealedSegment>>())?
727 .checked_add(
728 self.statistics
729 .capacity()
730 .checked_mul(std::mem::size_of::<Arc<LiveSegmentStatistics>>())?,
731 )
732 }
733
734 pub fn terms(&self) -> impl Iterator<Item = &[u8]> {
739 self.segments.iter().flat_map(|segment| segment.terms())
740 }
741
742 pub(crate) fn vocabulary_terms(&self) -> impl Iterator<Item = (&[u8], FieldId)> + Clone {
743 self.segments
744 .iter()
745 .flat_map(|segment| segment.vocabulary_terms())
746 }
747
748 #[must_use]
750 pub fn document_count(&self) -> u64 {
751 self.statistics
752 .iter()
753 .map(|statistics| statistics.counters.documents)
754 .fold(0_u64, u64::saturating_add)
755 }
756
757 #[must_use]
759 pub fn total_tokens(&self) -> u64 {
760 self.statistics
761 .iter()
762 .map(|statistics| statistics.counters.tokens)
763 .fold(0_u64, u64::saturating_add)
764 }
765
766 pub fn corpus_stats(&self) -> Result<CorpusStats, IndexError> {
776 #[cfg(any(test, feature = "test-support"))]
777 super::preparation_observer::corpus_statistics();
778 Ok(CorpusStats::new(
779 self.document_count(),
780 self.total_tokens(),
781 )?)
782 }
783
784 #[must_use]
793 pub fn document_frequency(&self, term: &[u8], fields: &[FieldId]) -> u32 {
794 #[cfg(any(test, feature = "test-support"))]
795 super::preparation_observer::frequency();
796 self.segments
797 .iter()
798 .zip(&self.statistics)
799 .map(|(segment, statistics)| {
800 let live_rows = &statistics.rows;
801 if live_rows.cardinality() == u64::from(segment.row_count()) {
802 segment.document_frequency(term, fields)
803 } else {
804 segment.live_document_frequency(term, fields, live_rows)
805 }
806 })
807 .fold(0_u32, u32::saturating_add)
808 }
809
810 #[inline]
814 pub(crate) fn prepared_document_frequency(
815 &self,
816 term: &[u8],
817 fields: &[FieldId],
818 ) -> Result<u32, IndexError> {
819 if !self.has_frequency_cache {
823 return Ok(self.document_frequency(term, fields));
824 }
825 self.cached_document_frequency(term, fields)
826 }
827
828 pub(crate) fn prepared_document_frequency_controlled<E>(
829 &self,
830 term: &[u8],
831 fields: &[FieldId],
832 mut check: impl FnMut() -> Result<(), E>,
833 ) -> Result<u32, super::search::ControlledSearchError<E>> {
834 use super::search::ControlledSearchError;
835 #[cfg(any(test, feature = "test-support"))]
836 super::preparation_observer::frequency();
837 check().map_err(ControlledSearchError::Control)?;
838 let mut total = 0_u32;
839 for (segment, statistics) in self.segments.iter().zip(&self.statistics) {
840 let live = &statistics.rows;
841 let frequency = if live.cardinality() == u64::from(segment.row_count()) {
842 segment
843 .document_frequency_controlled(term, fields, &mut check)
844 .map_err(ControlledSearchError::Control)?
845 } else if let Some(cache) = &statistics.frequency_cache {
846 cache.frequency_controlled(segment, live, term, fields, &mut check)?
847 } else {
848 segment
849 .live_document_frequency_controlled(term, fields, live, &mut check)
850 .map_err(ControlledSearchError::Control)?
851 };
852 total = total.saturating_add(frequency);
853 }
854 check().map_err(ControlledSearchError::Control)?;
855 Ok(total)
856 }
857
858 fn cached_document_frequency(
859 &self,
860 term: &[u8],
861 fields: &[FieldId],
862 ) -> Result<u32, IndexError> {
863 #[cfg(any(test, feature = "test-support"))]
864 super::preparation_observer::frequency();
865 self.segments.iter().zip(&self.statistics).try_fold(
866 0_u32,
867 |total, (segment, statistics)| {
868 let live = &statistics.rows;
869 let frequency = if live.cardinality() == u64::from(segment.row_count()) {
870 segment.document_frequency(term, fields)
871 } else if let Some(cache) = &statistics.frequency_cache {
872 cache.frequency(segment, live, term, fields)?
873 } else {
874 segment.live_document_frequency(term, fields, live)
875 };
876 Ok(total.saturating_add(frequency))
877 },
878 )
879 }
880}
881
882fn live_segment_counters_controlled<E: From<IndexError>>(
883 ordinal: usize,
884 segment: &SealedSegment,
885 live_rows: &DocBitmap,
886 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
887) -> Result<LiveSegmentCounters, E> {
888 work.check_now()?;
889 if let Some(row) = live_rows.first_at_or_after(segment.row_count()) {
890 return Err(IndexError::LiveRowOutOfRange {
891 segment: ordinal,
892 row,
893 row_count: segment.row_count(),
894 }
895 .into());
896 }
897 let tokens =
898 live_rows.iter().try_fold(0_u64, |total, row| {
899 work.step()?;
900 #[cfg(any(test, feature = "test-support"))]
901 super::preparation_observer::live_statistics_row();
902 let slot = usize::try_from(row).map_err(|_| IndexError::LiveLengthMissing {
903 segment: ordinal,
904 row,
905 })?;
906 let length = segment.total_lengths().get(slot).copied().ok_or(
907 IndexError::LiveLengthMissing {
908 segment: ordinal,
909 row,
910 },
911 )?;
912 Ok::<u64, E>(total.saturating_add(u64::from(length)))
913 })?;
914 work.check_now()?;
915 Ok(LiveSegmentCounters {
916 documents: live_rows.cardinality(),
917 tokens,
918 })
919}
920
921#[cfg(test)]
922#[allow(
923 clippy::expect_used,
924 clippy::indexing_slicing,
925 clippy::panic,
926 clippy::unwrap_used
927)]
928mod tests {
929
930 #[test]
931 fn parallel_analysis_is_identical_to_the_sequential_loop() {
932 let analyzer = analyzer();
937 let documents: Vec<Document> = (0..97)
938 .map(|index| {
939 let mut document = Document::new();
940 document.set(
941 FieldId(0),
942 &format!("alpha beta{} gamma-{index} 401k", index % 7),
943 );
944 document.set(FieldId(1), &format!("delta epsilon{} the quick", index % 5));
945 document
946 })
947 .collect();
948 let mut sequential = SegmentIndex::new();
949 for document in &documents {
950 sequential
951 .push_document(&analyzer, document)
952 .expect("indexable");
953 }
954 for threads in [1_usize, 3, 8] {
955 let mut parallel = SegmentIndex::new();
956 parallel
957 .push_documents(&analyzer, &documents, threads)
958 .expect("indexable");
959 assert_eq!(parallel.row_count(), sequential.row_count());
960 let ours: Vec<_> = parallel.postings().collect();
961 let oracle: Vec<_> = sequential.postings().collect();
962 assert_eq!(
963 ours.len(),
964 oracle.len(),
965 "term count diverged at {threads} threads"
966 );
967 for ((key, list), (oracle_key, oracle_list)) in ours.iter().zip(&oracle) {
968 assert_eq!(key, oracle_key);
969 assert_eq!(
970 list.postings(),
971 oracle_list.postings(),
972 "postings diverged for {:?} at {threads} threads",
973 String::from_utf8_lossy(&key.term)
974 );
975 }
976 for field in [FieldId(0), FieldId(1)] {
977 assert_eq!(
978 parallel.field_lengths(field),
979 sequential.field_lengths(field),
980 "lengths diverged at {threads} threads"
981 );
982 }
983 }
984 }
985
986 use super::*;
987 use crate::fts::tokenizer::{Profile, TokenizerConfig};
988
989 #[test]
990 fn astra_18_live_statistics_cancel_inside_row_walk() {
991 #[derive(Debug)]
992 enum Error {
993 Index(IndexError),
994 Cancelled,
995 }
996 impl From<IndexError> for Error {
997 fn from(error: IndexError) -> Self {
998 Self::Index(error)
999 }
1000 }
1001 let segment = SealedSegment::seal(&segment_of(&analyzer(), &vec!["alpha beta"; 4_097]))
1002 .expect("sealed row fixture");
1003 let live = DocBitmap::from_ids(0..4_096);
1004 super::super::preparation_observer::begin();
1005 let mut work = super::super::control::WorkCheck::new(|| {
1006 if super::super::preparation_observer::live_statistics_rows() >= 64 {
1007 Err(Error::Cancelled)
1008 } else {
1009 Ok(())
1010 }
1011 });
1012 let result = LiveSegmentStatistics::build_controlled(7, &segment, &live, &mut work);
1013 let visited = super::super::preparation_observer::live_statistics_rows();
1014 super::super::preparation_observer::take();
1015 println!("live statistics rows={visited}");
1016 assert!(matches!(result, Err(Error::Cancelled)));
1017 assert!((64..=128).contains(&visited));
1018 let mut clean = super::super::control::WorkCheck::new(|| Ok::<(), Error>(()));
1019 let value = LiveSegmentStatistics::build_controlled(7, &segment, &live, &mut clean)
1020 .expect("fresh control");
1021 assert_eq!(value.counters.documents, 4_096);
1022 assert_eq!(value.counters.tokens, 8_192);
1023 assert_eq!(value.rows, live);
1024 assert!(value.frequency_cache.is_none());
1025 assert!(matches!(
1026 LiveSegmentStatistics::build_controlled(
1027 7,
1028 &segment,
1029 &DocBitmap::from_ids([4_097]),
1030 &mut clean
1031 ),
1032 Err(Error::Index(IndexError::LiveRowOutOfRange {
1033 segment: 7,
1034 row: 4_097,
1035 row_count: 4_097
1036 }))
1037 ));
1038 }
1039
1040 fn analyzer() -> Analyzer {
1041 Analyzer::new(TokenizerConfig::text_default()).expect("valid config")
1042 }
1043
1044 fn code_analyzer() -> Analyzer {
1045 Analyzer::new(Profile::Code.config()).expect("valid config")
1046 }
1047
1048 fn segment_of(analyzer: &Analyzer, texts: &[&str]) -> SegmentIndex {
1049 let mut segment = SegmentIndex::new();
1050 for text in texts {
1051 segment
1052 .push_document(analyzer, &Document::with_text(text))
1053 .expect("indexable");
1054 }
1055 segment
1056 }
1057
1058 #[test]
1059 fn rows_are_dense_and_ascending() {
1060 let analyzer = analyzer();
1061 let mut segment = SegmentIndex::new();
1062 for (expected, text) in ["alpha", "beta", "gamma"].into_iter().enumerate() {
1063 let row = segment
1064 .push_document(&analyzer, &Document::with_text(text))
1065 .expect("indexable");
1066 assert_eq!(row, u32::try_from(expected).expect("small"));
1067 }
1068 assert_eq!(segment.row_count(), 3);
1069 }
1070
1071 #[test]
1072 fn document_frequency_is_summed_across_segments_not_per_segment() {
1073 let analyzer = code_analyzer();
1074 let mut index = LexicalIndex::new();
1075 index
1076 .push_segment(segment_of(&analyzer, &["engine", "engine"]))
1077 .expect("seals");
1078 index
1079 .push_segment(segment_of(&analyzer, &["engine", "other"]))
1080 .expect("seals");
1081 assert_eq!(index.document_count(), 4);
1082 assert_eq!(index.document_frequency(b"engine", &[DEFAULT_FIELD]), 3);
1084 }
1085
1086 #[test]
1087 fn a_term_in_two_fields_of_one_document_counts_once() {
1088 let analyzer = code_analyzer();
1089 let mut segment = SegmentIndex::new();
1090 let mut document = Document::new();
1091 document.set(FieldId(0), "engine");
1092 document.set(FieldId(1), "engine");
1093 segment
1094 .push_document(&analyzer, &document)
1095 .expect("indexable");
1096 let mut index = LexicalIndex::new();
1097 index.push_segment(segment).expect("seals");
1098 assert_eq!(
1099 index.document_frequency(b"engine", &[FieldId(0), FieldId(1)]),
1100 1
1101 );
1102 }
1103
1104 #[test]
1105 fn corpus_stats_span_every_segment() {
1106 let analyzer = code_analyzer();
1107 let mut index = LexicalIndex::new();
1108 index
1109 .push_segment(segment_of(&analyzer, &["a b c"]))
1110 .expect("seals");
1111 index
1112 .push_segment(segment_of(&analyzer, &["d e f g h"]))
1113 .expect("seals");
1114 let stats = index.corpus_stats().expect("two documents");
1115 assert_eq!(stats.document_count(), 2);
1116 assert_eq!(stats.total_tokens(), 8);
1117 assert!((stats.average_document_length() - 4.0).abs() < 1e-12);
1118 }
1119
1120 #[test]
1121 fn live_row_counters_and_document_frequency_exclude_tombstones() {
1122 let analyzer = code_analyzer();
1123 let sealed = SealedSegment::seal(&segment_of(
1124 &analyzer,
1125 &["engine padding padding", "engine"],
1126 ))
1127 .expect("seal lexical segment");
1128 let live_rows = DocBitmap::from_ids([1]);
1129 let mut index = LexicalIndex::new();
1130 index
1131 .push_sealed_with_live_rows(sealed, &live_rows)
1132 .expect("valid live rows");
1133
1134 let stats = index.corpus_stats().expect("one live document");
1135 assert_eq!(stats.document_count(), 1);
1136 assert_eq!(stats.total_tokens(), 1);
1137 assert_eq!(
1138 index.document_frequency(b"engine", &[DEFAULT_FIELD]),
1139 1,
1140 "document frequency must use the same live rows as corpus stats"
1141 );
1142 }
1143
1144 #[test]
1145 fn an_empty_index_refuses_to_produce_statistics() {
1146 let index = LexicalIndex::new();
1147 assert!(index.corpus_stats().is_err());
1148 }
1149
1150 #[test]
1151 fn document_length_counts_positions_not_tokens() {
1152 let analyzer = code_analyzer();
1156 let mut segment = SegmentIndex::new();
1157 segment
1158 .push_document(&analyzer, &Document::with_text("put_if_match"))
1159 .expect("indexable");
1160 assert_eq!(segment.document_length(0), 3);
1162 }
1163
1164 #[test]
1165 fn field_lengths_are_tracked_separately_and_summed() {
1166 let analyzer = code_analyzer();
1167 let mut segment = SegmentIndex::new();
1168 let mut document = Document::new();
1169 document.set(FieldId(0), "one two");
1170 document.set(FieldId(1), "three four five");
1171 segment
1172 .push_document(&analyzer, &document)
1173 .expect("indexable");
1174 assert_eq!(segment.field_length(0, FieldId(0)), 2);
1175 assert_eq!(segment.field_length(0, FieldId(1)), 3);
1176 assert_eq!(segment.document_length(0), 5);
1177 assert_eq!(segment.total_tokens(), 5);
1178 assert_eq!(segment.field_length(0, FieldId(9)), 0);
1179 }
1180
1181 #[test]
1182 fn posting_lists_carry_positions() {
1183 let analyzer = code_analyzer();
1184 let segment = segment_of(&analyzer, &["alpha beta alpha"]);
1185 let list = segment
1186 .posting_list(b"alpha", DEFAULT_FIELD)
1187 .expect("alpha present");
1188 let posting = list.postings().first().expect("one posting");
1189 assert_eq!(posting.tf, 2);
1190 assert_eq!(posting.positions, vec![0, 2]);
1191 }
1192
1193 #[test]
1194 fn an_absent_term_has_no_posting_list_and_zero_frequency() {
1195 let analyzer = code_analyzer();
1196 let segment = segment_of(&analyzer, &["alpha"]);
1197 assert!(segment.posting_list(b"omega", DEFAULT_FIELD).is_none());
1198 let mut index = LexicalIndex::new();
1199 index.push_segment(segment).expect("seals");
1200 assert_eq!(index.document_frequency(b"omega", &[DEFAULT_FIELD]), 0);
1201 }
1202
1203 #[test]
1204 fn errors_render_useful_text() {
1205 assert!(
1206 IndexError::RowsNotAscending { row: 3 }
1207 .to_string()
1208 .contains("row order")
1209 );
1210 assert!(
1211 IndexError::Stats(Bm25Error::EmptyCorpus)
1212 .to_string()
1213 .contains("statistics")
1214 );
1215 assert!(
1216 IndexError::Postings(PostingsError::ZeroTermFrequency)
1217 .to_string()
1218 .contains("postings")
1219 );
1220 }
1221}