1use std::collections::{HashMap, HashSet};
8
9#[derive(Debug, Clone, PartialEq)]
13pub enum IndexError {
14 DocumentAlreadyExists(String),
16 DocumentNotFound(String),
18}
19
20impl std::fmt::Display for IndexError {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 Self::DocumentAlreadyExists(id) => {
24 write!(f, "document already exists: {id}")
25 }
26 Self::DocumentNotFound(id) => {
27 write!(f, "document not found: {id}")
28 }
29 }
30 }
31}
32
33impl std::error::Error for IndexError {}
34
35#[derive(Debug, Clone)]
39pub struct IndexedDocument {
40 pub doc_id: String,
42 pub content: String,
44 pub fields: HashMap<String, String>,
46 pub embedding: Option<Vec<f64>>,
48 pub indexed_at: u64,
50}
51
52impl IndexedDocument {
53 pub fn new(doc_id: impl Into<String>, content: impl Into<String>) -> Self {
55 Self {
56 doc_id: doc_id.into(),
57 content: content.into(),
58 fields: HashMap::new(),
59 embedding: None,
60 indexed_at: 0,
61 }
62 }
63
64 pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
66 self.fields.insert(key.into(), value.into());
67 self
68 }
69
70 pub fn with_embedding(mut self, emb: Vec<f64>) -> Self {
72 self.embedding = Some(emb);
73 self
74 }
75
76 pub fn with_indexed_at(mut self, ts: u64) -> Self {
78 self.indexed_at = ts;
79 self
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct PostingEntry {
86 pub doc_id: String,
88 pub term_freq: u32,
90 pub positions: Vec<u32>,
92}
93
94#[derive(Debug, Clone, Default)]
96pub struct InvertedIndex {
97 pub term_to_postings: HashMap<String, Vec<PostingEntry>>,
99 pub doc_freq: HashMap<String, u32>,
101 pub total_docs: u32,
103 pub avg_doc_length: f64,
105}
106
107#[derive(Debug, Clone)]
109pub struct SearchResult {
110 pub doc_id: String,
112 pub score: f64,
114 pub matched_terms: Vec<String>,
116 pub snippet: Option<String>,
118}
119
120#[derive(Debug, Clone)]
122pub struct FacetFilter {
123 pub field: String,
125 pub value: String,
127}
128
129impl FacetFilter {
130 pub fn new(field: impl Into<String>, value: impl Into<String>) -> Self {
132 Self {
133 field: field.into(),
134 value: value.into(),
135 }
136 }
137}
138
139#[derive(Debug, Clone)]
141pub struct IndexQuery {
142 pub terms: Vec<String>,
144 pub facets: Vec<FacetFilter>,
146 pub top_k: usize,
148 pub min_score: f64,
150 pub require_all_terms: bool,
153}
154
155impl IndexQuery {
156 pub fn new(terms: impl IntoIterator<Item = impl Into<String>>) -> Self {
158 Self {
159 terms: terms.into_iter().map(|t| t.into()).collect(),
160 facets: Vec::new(),
161 top_k: 10,
162 min_score: 0.0,
163 require_all_terms: false,
164 }
165 }
166}
167
168#[derive(Debug, Clone)]
170pub struct IndexStats {
171 pub doc_count: usize,
173 pub vocabulary_size: usize,
175 pub avg_doc_length: f64,
177 pub total_postings: usize,
179}
180
181fn tokenize(text: &str, stopwords: &HashSet<String>) -> Vec<String> {
185 text.split(|c: char| !c.is_alphanumeric())
186 .filter_map(|tok| {
187 let lower = tok.to_lowercase();
188 if lower.is_empty() || stopwords.contains(&lower) {
189 None
190 } else {
191 Some(lower)
192 }
193 })
194 .collect()
195}
196
197fn default_stopwords() -> HashSet<String> {
199 [
200 "the", "a", "an", "is", "it", "in", "on", "at", "to", "of", "and", "or", "but", "for",
201 "with", "this", "that", "are", "was", "were", "be", "been", "have", "has", "had", "do",
202 "does", "did", "will", "would", "could", "should",
203 ]
204 .iter()
205 .map(|s| s.to_string())
206 .collect()
207}
208
209const BM25_K1: f64 = 1.5;
212const BM25_B: f64 = 0.75;
213
214#[inline]
218fn bm25_idf(n: u32, df: u32) -> f64 {
219 let n_f = n as f64;
220 let df_f = df as f64;
221 ((n_f - df_f + 0.5) / (df_f + 0.5) + 1.0).ln()
222}
223
224#[inline]
228fn bm25_tf(tf: u32, doc_len: u32, avg_dl: f64) -> f64 {
229 let tf_f = tf as f64;
230 let dl_f = doc_len as f64;
231 tf_f * (BM25_K1 + 1.0) / (tf_f + BM25_K1 * (1.0 - BM25_B + BM25_B * dl_f / avg_dl.max(1.0)))
232}
233
234pub struct CorpusIndexer {
254 pub index: InvertedIndex,
256 pub documents: HashMap<String, IndexedDocument>,
258 pub stopwords: HashSet<String>,
260 doc_lengths: HashMap<String, u32>,
262}
263
264impl Default for CorpusIndexer {
265 fn default() -> Self {
266 Self::new()
267 }
268}
269
270impl CorpusIndexer {
271 pub fn new() -> Self {
275 Self {
276 index: InvertedIndex::default(),
277 documents: HashMap::new(),
278 stopwords: default_stopwords(),
279 doc_lengths: HashMap::new(),
280 }
281 }
282
283 pub fn add_document(&mut self, doc: IndexedDocument) -> Result<(), IndexError> {
292 if self.documents.contains_key(&doc.doc_id) {
293 return Err(IndexError::DocumentAlreadyExists(doc.doc_id.clone()));
294 }
295 self.index_document(&doc);
296 self.documents.insert(doc.doc_id.clone(), doc);
297 self.refresh_avg_doc_length();
298 Ok(())
299 }
300
301 pub fn remove_document(&mut self, doc_id: &str) -> Result<(), IndexError> {
307 if !self.documents.contains_key(doc_id) {
308 return Err(IndexError::DocumentNotFound(doc_id.to_string()));
309 }
310 self.unindex_document(doc_id);
311 self.documents.remove(doc_id);
312 self.doc_lengths.remove(doc_id);
313 self.refresh_avg_doc_length();
314 Ok(())
315 }
316
317 pub fn update_document(&mut self, doc: IndexedDocument) -> Result<(), IndexError> {
325 let id = doc.doc_id.clone();
326 self.remove_document(&id)?;
327 self.add_document(doc)
329 .map_err(|_| IndexError::DocumentNotFound(id))
330 }
331
332 pub fn search(&self, query: &IndexQuery) -> Vec<SearchResult> {
346 if self.index.total_docs == 0 || query.terms.is_empty() {
347 return Vec::new();
348 }
349
350 let norm_terms: Vec<String> = query
352 .terms
353 .iter()
354 .filter_map(|t| {
355 let lower = t.to_lowercase();
356 if lower.is_empty() || self.stopwords.contains(&lower) {
357 None
358 } else {
359 Some(lower)
360 }
361 })
362 .collect();
363
364 if norm_terms.is_empty() {
365 return Vec::new();
366 }
367
368 let avg_dl = self.index.avg_doc_length;
369 let n = self.index.total_docs;
370
371 let mut scores: HashMap<&str, (f64, Vec<String>)> = HashMap::new();
373
374 for term in &norm_terms {
375 let df = self.index.doc_freq.get(term).copied().unwrap_or(0);
376 if df == 0 {
377 continue;
378 }
379 let idf = bm25_idf(n, df);
380
381 if let Some(postings) = self.index.term_to_postings.get(term) {
382 for entry in postings {
383 let dl = self
384 .doc_lengths
385 .get(entry.doc_id.as_str())
386 .copied()
387 .unwrap_or(1);
388 let tf_sat = bm25_tf(entry.term_freq, dl, avg_dl);
389 let contribution = idf * tf_sat;
390
391 let slot = scores
392 .entry(entry.doc_id.as_str())
393 .or_insert((0.0, Vec::new()));
394 slot.0 += contribution;
395 if !slot.1.contains(term) {
396 slot.1.push(term.clone());
397 }
398 }
399 }
400 }
401
402 if query.require_all_terms {
404 scores.retain(|_, (_, matched)| norm_terms.iter().all(|t| matched.contains(t)));
405 }
406
407 if !query.facets.is_empty() {
409 scores.retain(|doc_id, _| {
410 if let Some(doc) = self.documents.get(*doc_id) {
411 query.facets.iter().all(|f| {
412 doc.fields
413 .get(&f.field)
414 .map(|v| v == &f.value)
415 .unwrap_or(false)
416 })
417 } else {
418 false
419 }
420 });
421 }
422
423 let mut results: Vec<SearchResult> = scores
425 .into_iter()
426 .filter(|(_, (score, _))| *score >= query.min_score)
427 .map(|(doc_id, (score, matched_terms))| {
428 let snippet = self.snippet(doc_id, &matched_terms, 8);
429 SearchResult {
430 doc_id: doc_id.to_string(),
431 score,
432 matched_terms,
433 snippet,
434 }
435 })
436 .collect();
437
438 results.sort_unstable_by(|a, b| {
440 b.score
441 .partial_cmp(&a.score)
442 .unwrap_or(std::cmp::Ordering::Equal)
443 .then_with(|| a.doc_id.cmp(&b.doc_id))
444 });
445
446 results.truncate(query.top_k);
447 results
448 }
449
450 pub fn snippet(&self, doc_id: &str, terms: &[String], window: usize) -> Option<String> {
458 let doc = self.documents.get(doc_id)?;
459 let tokens: Vec<&str> = doc.content.split_whitespace().collect();
460
461 let lower_terms: Vec<String> = terms.iter().map(|t| t.to_lowercase()).collect();
463
464 let pos = tokens.iter().position(|&w| {
465 let lw = w.to_lowercase();
466 let stripped: String = lw.chars().filter(|c| c.is_alphanumeric()).collect();
468 lower_terms.iter().any(|t| t == &stripped || t == &lw)
469 })?;
470
471 let start = pos.saturating_sub(window);
472 let end = (pos + window + 1).min(tokens.len());
473 Some(tokens[start..end].join(" "))
474 }
475
476 pub fn term_frequency(&self, term: &str, doc_id: &str) -> u32 {
478 let lower = term.to_lowercase();
479 self.index
480 .term_to_postings
481 .get(&lower)
482 .and_then(|postings| postings.iter().find(|e| e.doc_id == doc_id))
483 .map(|e| e.term_freq)
484 .unwrap_or(0)
485 }
486
487 pub fn document_frequency(&self, term: &str) -> u32 {
489 let lower = term.to_lowercase();
490 self.index.doc_freq.get(&lower).copied().unwrap_or(0)
491 }
492
493 pub fn doc_count(&self) -> usize {
495 self.documents.len()
496 }
497
498 pub fn vocabulary_size(&self) -> usize {
500 self.index.term_to_postings.len()
501 }
502
503 pub fn top_terms(&self, n: usize) -> Vec<(String, u32)> {
507 let mut pairs: Vec<(String, u32)> = self
508 .index
509 .doc_freq
510 .iter()
511 .map(|(k, &v)| (k.clone(), v))
512 .collect();
513 pairs.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
514 pairs.truncate(n);
515 pairs
516 }
517
518 pub fn documents_with_field<'a>(&'a self, field: &str, value: &str) -> Vec<&'a str> {
520 self.documents
521 .values()
522 .filter(|doc| {
523 doc.fields
524 .get(field)
525 .map(|v| v.as_str() == value)
526 .unwrap_or(false)
527 })
528 .map(|doc| doc.doc_id.as_str())
529 .collect()
530 }
531
532 pub fn stats(&self) -> IndexStats {
534 let total_postings: usize = self.index.term_to_postings.values().map(|v| v.len()).sum();
535 IndexStats {
536 doc_count: self.doc_count(),
537 vocabulary_size: self.vocabulary_size(),
538 avg_doc_length: self.index.avg_doc_length,
539 total_postings,
540 }
541 }
542
543 fn index_document(&mut self, doc: &IndexedDocument) {
547 let tokens = tokenize(&doc.content, &self.stopwords);
548 let doc_len = tokens.len() as u32;
549 self.doc_lengths.insert(doc.doc_id.clone(), doc_len);
550 self.index.total_docs += 1;
551
552 let mut term_positions: HashMap<String, Vec<u32>> = HashMap::new();
554 for (pos, token) in tokens.iter().enumerate() {
555 term_positions
556 .entry(token.clone())
557 .or_default()
558 .push(pos as u32);
559 }
560
561 for (term, positions) in term_positions {
562 let tf = positions.len() as u32;
563 self.index
565 .term_to_postings
566 .entry(term.clone())
567 .or_default()
568 .push(PostingEntry {
569 doc_id: doc.doc_id.clone(),
570 term_freq: tf,
571 positions,
572 });
573 *self.index.doc_freq.entry(term).or_insert(0) += 1;
575 }
576 }
577
578 fn unindex_document(&mut self, doc_id: &str) {
580 let affected_terms: Vec<String> = self
582 .index
583 .term_to_postings
584 .iter()
585 .filter(|(_, postings)| postings.iter().any(|e| e.doc_id == doc_id))
586 .map(|(term, _)| term.clone())
587 .collect();
588
589 for term in affected_terms {
590 if let Some(postings) = self.index.term_to_postings.get_mut(&term) {
591 postings.retain(|e| e.doc_id != doc_id);
592 if postings.is_empty() {
593 self.index.term_to_postings.remove(&term);
594 self.index.doc_freq.remove(&term);
595 } else {
596 if let Some(df) = self.index.doc_freq.get_mut(&term) {
598 *df = df.saturating_sub(1);
599 }
600 }
601 }
602 }
603 self.index.total_docs = self.index.total_docs.saturating_sub(1);
604 }
605
606 fn refresh_avg_doc_length(&mut self) {
608 if self.doc_lengths.is_empty() {
609 self.index.avg_doc_length = 0.0;
610 } else {
611 let total: u64 = self.doc_lengths.values().map(|&v| v as u64).sum();
612 self.index.avg_doc_length = total as f64 / self.doc_lengths.len() as f64;
613 }
614 }
615}
616
617#[cfg(test)]
620mod tests {
621 use super::{
622 bm25_idf, bm25_tf, default_stopwords, tokenize, CorpusIndexer, FacetFilter, IndexError,
623 IndexQuery, IndexedDocument, PostingEntry,
624 };
625
626 fn make_doc(id: &str, content: &str) -> IndexedDocument {
629 IndexedDocument::new(id, content)
630 }
631
632 #[test]
635 fn tokenize_basic() {
636 let sw = default_stopwords();
637 let tokens = tokenize("Hello, world! This is a test.", &sw);
638 assert!(tokens.contains(&"hello".to_string()));
640 assert!(tokens.contains(&"world".to_string()));
641 assert!(tokens.contains(&"test".to_string()));
642 assert!(!tokens.contains(&"this".to_string()));
643 assert!(!tokens.contains(&"is".to_string()));
644 }
645
646 #[test]
647 fn tokenize_lowercase() {
648 let sw = default_stopwords();
649 let tokens = tokenize("RUST Programming", &sw);
650 assert!(tokens.contains(&"rust".to_string()));
651 assert!(tokens.contains(&"programming".to_string()));
652 }
653
654 #[test]
655 fn tokenize_empty_string() {
656 let sw = default_stopwords();
657 let tokens = tokenize("", &sw);
658 assert!(tokens.is_empty());
659 }
660
661 #[test]
662 fn tokenize_only_stopwords() {
663 let sw = default_stopwords();
664 let tokens = tokenize("the and or but", &sw);
665 assert!(tokens.is_empty());
666 }
667
668 #[test]
669 fn tokenize_numbers_are_kept() {
670 let sw = default_stopwords();
671 let tokens = tokenize("version 2024 release", &sw);
672 assert!(tokens.contains(&"2024".to_string()));
673 }
674
675 #[test]
678 fn bm25_idf_positive_for_rare_term() {
679 let idf = bm25_idf(100, 1);
681 assert!(idf > 0.0);
682 }
683
684 #[test]
685 fn bm25_idf_decreases_with_df() {
686 let idf_rare = bm25_idf(100, 1);
687 let idf_common = bm25_idf(100, 50);
688 assert!(idf_rare > idf_common);
689 }
690
691 #[test]
692 fn bm25_tf_increases_with_raw_tf() {
693 let tf1 = bm25_tf(1, 100, 100.0);
694 let tf5 = bm25_tf(5, 100, 100.0);
695 assert!(tf5 > tf1);
696 }
697
698 #[test]
699 fn bm25_tf_saturates() {
700 let tf_large = bm25_tf(1_000_000, 100, 100.0);
702 assert!(tf_large < 10.0, "BM25 TF saturation failed: {tf_large}");
703 }
704
705 #[test]
708 fn add_document_increases_doc_count() {
709 let mut idx = CorpusIndexer::new();
710 idx.add_document(make_doc("d1", "rust is great"))
711 .expect("test: add_document should succeed");
712 assert_eq!(idx.doc_count(), 1);
713 }
714
715 #[test]
716 fn add_document_duplicate_returns_error() {
717 let mut idx = CorpusIndexer::new();
718 idx.add_document(make_doc("d1", "content"))
719 .expect("test: add_document should succeed");
720 let err = idx
721 .add_document(make_doc("d1", "other"))
722 .expect_err("test: duplicate add_document should return error");
723 assert_eq!(err, IndexError::DocumentAlreadyExists("d1".to_string()));
724 }
725
726 #[test]
727 fn add_document_builds_postings() {
728 let mut idx = CorpusIndexer::new();
729 idx.add_document(make_doc("d1", "rust programming language"))
730 .expect("test: add_document should succeed");
731 assert!(idx.index.term_to_postings.contains_key("rust"));
732 assert!(idx.index.term_to_postings.contains_key("programming"));
733 assert!(idx.index.term_to_postings.contains_key("language"));
734 }
735
736 #[test]
737 fn add_document_updates_avg_doc_length() {
738 let mut idx = CorpusIndexer::new();
739 idx.add_document(make_doc("d1", "rust programming"))
740 .expect("test: add_document should succeed");
741 assert!(idx.index.avg_doc_length > 0.0);
742 }
743
744 #[test]
745 fn add_multiple_documents() {
746 let mut idx = CorpusIndexer::new();
747 for i in 0..5 {
748 idx.add_document(make_doc(&format!("d{i}"), &format!("document {i} content")))
749 .expect("test: add_document should succeed");
750 }
751 assert_eq!(idx.doc_count(), 5);
752 }
753
754 #[test]
757 fn remove_document_decreases_count() {
758 let mut idx = CorpusIndexer::new();
759 idx.add_document(make_doc("d1", "rust programming"))
760 .expect("test: add_document should succeed");
761 idx.remove_document("d1")
762 .expect("test: remove_document should succeed");
763 assert_eq!(idx.doc_count(), 0);
764 }
765
766 #[test]
767 fn remove_document_not_found_returns_error() {
768 let mut idx = CorpusIndexer::new();
769 let err = idx
770 .remove_document("missing")
771 .expect_err("test: remove_document should return error for missing id");
772 assert_eq!(err, IndexError::DocumentNotFound("missing".to_string()));
773 }
774
775 #[test]
776 fn remove_document_cleans_postings() {
777 let mut idx = CorpusIndexer::new();
778 idx.add_document(make_doc("d1", "rust programming"))
779 .expect("test: add_document should succeed");
780 idx.remove_document("d1")
781 .expect("test: remove_document should succeed");
782 assert!(!idx.index.term_to_postings.contains_key("rust"));
784 }
785
786 #[test]
787 fn remove_one_of_two_docs_keeps_shared_term() {
788 let mut idx = CorpusIndexer::new();
789 idx.add_document(make_doc("d1", "rust programming language"))
790 .expect("test: add_document should succeed");
791 idx.add_document(make_doc("d2", "rust systems"))
792 .expect("test: add_document should succeed");
793 idx.remove_document("d1")
794 .expect("test: remove_document should succeed");
795 assert!(idx.index.term_to_postings.contains_key("rust"));
797 assert_eq!(idx.document_frequency("rust"), 1);
798 }
799
800 #[test]
803 fn update_document_changes_content() {
804 let mut idx = CorpusIndexer::new();
805 idx.add_document(make_doc("d1", "old content words"))
806 .expect("test: add_document should succeed");
807 let updated = make_doc("d1", "brand new text");
808 idx.update_document(updated)
809 .expect("test: update_document should succeed");
810 assert_eq!(idx.doc_count(), 1);
811 assert!(idx.index.term_to_postings.contains_key("brand"));
812 assert!(!idx.index.term_to_postings.contains_key("old"));
813 }
814
815 #[test]
816 fn update_document_not_found_returns_error() {
817 let mut idx = CorpusIndexer::new();
818 let err = idx
819 .update_document(make_doc("ghost", "content"))
820 .expect_err("test: update_document should return error for nonexistent document");
821 assert_eq!(err, IndexError::DocumentNotFound("ghost".to_string()));
822 }
823
824 #[test]
827 fn term_frequency_counts_correctly() {
828 let mut idx = CorpusIndexer::new();
829 idx.add_document(make_doc("d1", "rust rust rust programming"))
830 .expect("test: add_document should succeed");
831 assert_eq!(idx.term_frequency("rust", "d1"), 3);
832 }
833
834 #[test]
835 fn term_frequency_missing_term() {
836 let mut idx = CorpusIndexer::new();
837 idx.add_document(make_doc("d1", "hello world"))
838 .expect("test: add_document should succeed");
839 assert_eq!(idx.term_frequency("rust", "d1"), 0);
840 }
841
842 #[test]
843 fn document_frequency_single() {
844 let mut idx = CorpusIndexer::new();
845 idx.add_document(make_doc("d1", "rust programming"))
846 .expect("test: add_document should succeed");
847 assert_eq!(idx.document_frequency("rust"), 1);
848 }
849
850 #[test]
851 fn document_frequency_multiple() {
852 let mut idx = CorpusIndexer::new();
853 idx.add_document(make_doc("d1", "rust programming"))
854 .expect("test: add_document should succeed");
855 idx.add_document(make_doc("d2", "rust systems"))
856 .expect("test: add_document should succeed");
857 assert_eq!(idx.document_frequency("rust"), 2);
858 }
859
860 #[test]
861 fn document_frequency_zero_for_absent() {
862 let idx = CorpusIndexer::new();
863 assert_eq!(idx.document_frequency("nonexistent"), 0);
864 }
865
866 #[test]
869 fn vocabulary_size_grows_with_new_terms() {
870 let mut idx = CorpusIndexer::new();
871 idx.add_document(make_doc("d1", "alpha beta gamma"))
872 .expect("test: add_document should succeed");
873 assert_eq!(idx.vocabulary_size(), 3);
874 }
875
876 #[test]
877 fn doc_count_empty() {
878 let idx = CorpusIndexer::new();
879 assert_eq!(idx.doc_count(), 0);
880 }
881
882 #[test]
885 fn top_terms_returns_by_df_descending() {
886 let mut idx = CorpusIndexer::new();
887 idx.add_document(make_doc("d1", "rust programming"))
890 .expect("test: add_document should succeed");
891 idx.add_document(make_doc("d2", "rust systems"))
892 .expect("test: add_document should succeed");
893 idx.add_document(make_doc("d3", "rust language"))
894 .expect("test: add_document should succeed");
895 let top = idx.top_terms(1);
896 assert_eq!(top[0].0, "rust");
897 assert_eq!(top[0].1, 3);
898 }
899
900 #[test]
901 fn top_terms_bounded_by_n() {
902 let mut idx = CorpusIndexer::new();
903 idx.add_document(make_doc("d1", "alpha beta gamma delta epsilon"))
904 .expect("test: add_document should succeed");
905 let top = idx.top_terms(3);
906 assert_eq!(top.len(), 3);
907 }
908
909 #[test]
912 fn documents_with_field_matches_exactly() {
913 let mut idx = CorpusIndexer::new();
914 let doc = make_doc("d1", "content").with_field("author", "alice");
915 idx.add_document(doc)
916 .expect("test: add_document should succeed");
917 let results = idx.documents_with_field("author", "alice");
918 assert_eq!(results, vec!["d1"]);
919 }
920
921 #[test]
922 fn documents_with_field_no_match() {
923 let mut idx = CorpusIndexer::new();
924 let doc = make_doc("d1", "content").with_field("author", "alice");
925 idx.add_document(doc)
926 .expect("test: add_document should succeed");
927 let results = idx.documents_with_field("author", "bob");
928 assert!(results.is_empty());
929 }
930
931 #[test]
932 fn documents_with_field_multiple_matches() {
933 let mut idx = CorpusIndexer::new();
934 for (id, author) in [("d1", "alice"), ("d2", "alice"), ("d3", "bob")] {
935 idx.add_document(make_doc(id, "text").with_field("author", author))
936 .expect("test: add_document should succeed");
937 }
938 let mut results = idx.documents_with_field("author", "alice");
939 results.sort_unstable();
940 assert_eq!(results, vec!["d1", "d2"]);
941 }
942
943 #[test]
946 fn snippet_returns_context_around_term() {
947 let mut idx = CorpusIndexer::new();
948 idx.add_document(make_doc(
949 "d1",
950 "the quick brown fox jumps over the lazy dog",
951 ))
952 .expect("test: add_document should succeed");
953 let terms = vec!["fox".to_string()];
954 let snip = idx
955 .snippet("d1", &terms, 2)
956 .expect("test: snippet should return Some for present term");
957 assert!(snip.contains("fox"), "snippet={snip}");
958 }
959
960 #[test]
961 fn snippet_returns_none_for_missing_doc() {
962 let idx = CorpusIndexer::new();
963 let result = idx.snippet("missing", &["term".to_string()], 3);
964 assert!(result.is_none());
965 }
966
967 #[test]
968 fn snippet_returns_none_for_absent_term() {
969 let mut idx = CorpusIndexer::new();
970 idx.add_document(make_doc("d1", "hello world"))
971 .expect("test: add_document should succeed");
972 let result = idx.snippet("d1", &["zzz".to_string()], 3);
973 assert!(result.is_none());
974 }
975
976 #[test]
977 fn snippet_window_clamps_at_boundaries() {
978 let mut idx = CorpusIndexer::new();
979 idx.add_document(make_doc("d1", "rust is fast"))
980 .expect("test: add_document should succeed");
981 let snip = idx
983 .snippet("d1", &["rust".to_string()], 5)
984 .expect("test: snippet should return Some when term is at doc boundary");
985 assert!(snip.contains("rust"));
986 }
987
988 #[test]
991 fn search_returns_matching_doc() {
992 let mut idx = CorpusIndexer::new();
993 idx.add_document(make_doc("d1", "rust systems programming language"))
994 .expect("test: add_document should succeed");
995 idx.add_document(make_doc("d2", "python machine learning library"))
996 .expect("test: add_document should succeed");
997
998 let q = IndexQuery::new(["rust"]);
999 let results = idx.search(&q);
1000 assert_eq!(results.len(), 1);
1001 assert_eq!(results[0].doc_id, "d1");
1002 }
1003
1004 #[test]
1005 fn search_empty_index_returns_empty() {
1006 let idx = CorpusIndexer::new();
1007 let q = IndexQuery::new(["rust"]);
1008 assert!(idx.search(&q).is_empty());
1009 }
1010
1011 #[test]
1012 fn search_no_match_returns_empty() {
1013 let mut idx = CorpusIndexer::new();
1014 idx.add_document(make_doc("d1", "hello world"))
1015 .expect("test: add_document should succeed");
1016 let q = IndexQuery::new(["rust"]);
1017 assert!(idx.search(&q).is_empty());
1018 }
1019
1020 #[test]
1021 fn search_or_mode_returns_partial_matches() {
1022 let mut idx = CorpusIndexer::new();
1023 idx.add_document(make_doc("d1", "rust language"))
1024 .expect("test: add_document should succeed");
1025 idx.add_document(make_doc("d2", "python language"))
1026 .expect("test: add_document should succeed");
1027 idx.add_document(make_doc("d3", "java language"))
1028 .expect("test: add_document should succeed");
1029
1030 let mut q = IndexQuery::new(["rust", "python"]);
1031 q.require_all_terms = false;
1032 let results = idx.search(&q);
1033 let ids: Vec<&str> = results.iter().map(|r| r.doc_id.as_str()).collect();
1034 assert!(ids.contains(&"d1"));
1035 assert!(ids.contains(&"d2"));
1036 assert!(!ids.contains(&"d3"));
1037 }
1038
1039 #[test]
1040 fn search_and_mode_requires_all_terms() {
1041 let mut idx = CorpusIndexer::new();
1042 idx.add_document(make_doc("d1", "rust systems fast"))
1043 .expect("test: add_document should succeed");
1044 idx.add_document(make_doc("d2", "rust language"))
1045 .expect("test: add_document should succeed");
1046
1047 let mut q = IndexQuery::new(["rust", "systems"]);
1048 q.require_all_terms = true;
1049 let results = idx.search(&q);
1050 assert_eq!(results.len(), 1);
1051 assert_eq!(results[0].doc_id, "d1");
1052 }
1053
1054 #[test]
1055 fn search_top_k_limits_results() {
1056 let mut idx = CorpusIndexer::new();
1057 for i in 0..10 {
1058 idx.add_document(make_doc(
1059 &format!("d{i}"),
1060 &format!("rust document number {i}"),
1061 ))
1062 .expect("test: add_document should succeed");
1063 }
1064 let mut q = IndexQuery::new(["rust"]);
1065 q.top_k = 3;
1066 let results = idx.search(&q);
1067 assert_eq!(results.len(), 3);
1068 }
1069
1070 #[test]
1071 fn search_sorted_by_score_desc() {
1072 let mut idx = CorpusIndexer::new();
1073 idx.add_document(make_doc("d1", "rust rust rust systems"))
1075 .expect("test: add_document should succeed");
1076 idx.add_document(make_doc("d2", "rust language"))
1078 .expect("test: add_document should succeed");
1079
1080 let q = IndexQuery::new(["rust"]);
1081 let results = idx.search(&q);
1082 assert!(results[0].score >= results[1].score);
1083 }
1084
1085 #[test]
1086 fn search_min_score_filters_low_scores() {
1087 let mut idx = CorpusIndexer::new();
1088 idx.add_document(make_doc("d1", "rust rust rust"))
1089 .expect("test: add_document should succeed");
1090 idx.add_document(make_doc("d2", "rust language"))
1091 .expect("test: add_document should succeed");
1092
1093 let mut q = IndexQuery::new(["rust"]);
1094 q.min_score = 999.0; let results = idx.search(&q);
1096 assert!(results.is_empty());
1097 }
1098
1099 #[test]
1100 fn search_facet_filter_applied() {
1101 let mut idx = CorpusIndexer::new();
1102 idx.add_document(make_doc("d1", "rust systems").with_field("lang", "rust"))
1103 .expect("test: add_document should succeed");
1104 idx.add_document(make_doc("d2", "python ml").with_field("lang", "python"))
1105 .expect("test: add_document should succeed");
1106
1107 let mut q = IndexQuery::new(["rust", "python", "systems", "ml"]);
1108 q.facets.push(FacetFilter::new("lang", "rust"));
1109 let results = idx.search(&q);
1110 assert_eq!(results.len(), 1);
1111 assert_eq!(results[0].doc_id, "d1");
1112 }
1113
1114 #[test]
1115 fn search_stopword_only_query_returns_empty() {
1116 let mut idx = CorpusIndexer::new();
1117 idx.add_document(make_doc("d1", "rust programming"))
1118 .expect("test: add_document should succeed");
1119 let q = IndexQuery::new(["the", "and", "or"]);
1120 assert!(idx.search(&q).is_empty());
1121 }
1122
1123 #[test]
1126 fn stats_reflect_current_state() {
1127 let mut idx = CorpusIndexer::new();
1128 idx.add_document(make_doc("d1", "alpha beta gamma"))
1129 .expect("test: add_document should succeed");
1130 let s = idx.stats();
1131 assert_eq!(s.doc_count, 1);
1132 assert_eq!(s.vocabulary_size, 3);
1133 assert!(s.avg_doc_length > 0.0);
1134 assert!(s.total_postings > 0);
1135 }
1136
1137 #[test]
1138 fn stats_after_removal_decrements_correctly() {
1139 let mut idx = CorpusIndexer::new();
1140 idx.add_document(make_doc("d1", "alpha beta"))
1141 .expect("test: add_document should succeed");
1142 idx.add_document(make_doc("d2", "gamma delta"))
1143 .expect("test: add_document should succeed");
1144 idx.remove_document("d1")
1145 .expect("test: remove_document should succeed");
1146 let s = idx.stats();
1147 assert_eq!(s.doc_count, 1);
1148 }
1149
1150 #[test]
1153 fn posting_entry_stores_positions() {
1154 let entry = PostingEntry {
1155 doc_id: "d1".to_string(),
1156 term_freq: 2,
1157 positions: vec![0, 5],
1158 };
1159 assert_eq!(entry.term_freq, 2);
1160 assert_eq!(entry.positions, vec![0, 5]);
1161 }
1162
1163 #[test]
1166 fn indexed_document_builder_chain() {
1167 let doc = IndexedDocument::new("id", "content")
1168 .with_field("author", "alice")
1169 .with_embedding(vec![0.1, 0.2])
1170 .with_indexed_at(12345);
1171 assert_eq!(doc.fields["author"], "alice");
1172 assert_eq!(
1173 doc.embedding
1174 .as_ref()
1175 .expect("test: embedding should be Some after with_embedding call")[0],
1176 0.1
1177 );
1178 assert_eq!(doc.indexed_at, 12345);
1179 }
1180
1181 #[test]
1184 fn search_query_terms_are_normalised() {
1185 let mut idx = CorpusIndexer::new();
1186 idx.add_document(make_doc("d1", "rust programming language"))
1187 .expect("test: add_document should succeed");
1188 let q = IndexQuery::new(["RUST"]);
1190 let results = idx.search(&q);
1191 assert!(!results.is_empty());
1192 }
1193
1194 #[test]
1195 fn remove_then_readd_same_id_succeeds() {
1196 let mut idx = CorpusIndexer::new();
1197 idx.add_document(make_doc("d1", "first content"))
1198 .expect("test: add_document should succeed");
1199 idx.remove_document("d1")
1200 .expect("test: remove_document should succeed");
1201 idx.add_document(make_doc("d1", "second content"))
1202 .expect("test: add_document should succeed");
1203 assert_eq!(idx.doc_count(), 1);
1204 assert!(idx.index.term_to_postings.contains_key("second"));
1205 assert!(!idx.index.term_to_postings.contains_key("first"));
1206 }
1207
1208 #[test]
1209 fn search_with_empty_terms_returns_empty() {
1210 let mut idx = CorpusIndexer::new();
1211 idx.add_document(make_doc("d1", "content"))
1212 .expect("test: add_document should succeed");
1213 let q = IndexQuery::new(Vec::<String>::new());
1214 assert!(idx.search(&q).is_empty());
1215 }
1216
1217 #[test]
1218 fn avg_doc_length_updates_on_add_and_remove() {
1219 let mut idx = CorpusIndexer::new();
1220 idx.add_document(make_doc("d1", "alpha beta gamma delta"))
1221 .expect("test: add_document should succeed");
1222 let len1 = idx.index.avg_doc_length;
1223 idx.add_document(make_doc("d2", "short"))
1224 .expect("test: add_document should succeed");
1225 let len2 = idx.index.avg_doc_length;
1226 assert_ne!(len1, len2);
1227 idx.remove_document("d2")
1228 .expect("test: remove_document should succeed");
1229 let len3 = idx.index.avg_doc_length;
1230 assert!((len1 - len3).abs() < 1e-9);
1231 }
1232
1233 #[test]
1234 fn search_result_has_matched_terms() {
1235 let mut idx = CorpusIndexer::new();
1236 idx.add_document(make_doc("d1", "rust systems programming"))
1237 .expect("test: add_document should succeed");
1238 let q = IndexQuery::new(["rust", "systems"]);
1239 let results = idx.search(&q);
1240 let matched = &results[0].matched_terms;
1241 assert!(matched.contains(&"rust".to_string()));
1242 assert!(matched.contains(&"systems".to_string()));
1243 }
1244}