1#![forbid(unsafe_code)]
24
25pub mod codec;
26
27#[cfg(feature = "positional")]
28pub mod positional;
29
30use std::borrow::Borrow;
31use std::collections::{HashMap, HashSet};
32use std::hash::Hash;
33
34pub type DocId = u32;
36
37pub trait Weight: Copy + Default + std::fmt::Debug + 'static {
43 fn zero() -> Self;
45 fn accumulate(&mut self, other: Self);
47 fn to_f32(self) -> f32;
49 fn to_doc_len(self) -> u64;
51}
52
53impl Weight for u32 {
54 #[inline]
55 fn zero() -> Self {
56 0
57 }
58 #[inline]
59 fn accumulate(&mut self, other: Self) {
60 *self += other;
61 }
62 #[inline]
63 fn to_f32(self) -> f32 {
64 self as f32
65 }
66 #[inline]
67 fn to_doc_len(self) -> u64 {
68 self as u64
69 }
70}
71
72impl Weight for f32 {
73 #[inline]
74 fn zero() -> Self {
75 0.0
76 }
77 #[inline]
78 fn accumulate(&mut self, other: Self) {
79 *self += other;
80 }
81 #[inline]
82 fn to_f32(self) -> f32 {
83 self
84 }
85 #[inline]
86 fn to_doc_len(self) -> u64 {
87 1
90 }
91}
92
93#[derive(thiserror::Error, Debug)]
95pub enum Error {
96 #[error("document already exists: {0}")]
98 DuplicateDocId(DocId),
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum CandidatePlan {
107 Candidates(Vec<DocId>),
109 ScanAll,
111}
112
113#[derive(Debug, Clone, Copy)]
115pub struct PlannerConfig {
116 pub max_candidate_ratio: f32,
120 pub max_candidates: u32,
122}
123
124impl Default for PlannerConfig {
125 fn default() -> Self {
126 Self {
127 max_candidate_ratio: 0.6,
129 max_candidates: 200_000,
130 }
131 }
132}
133
134#[derive(Debug)]
138#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
139#[cfg_attr(
140 feature = "serde",
141 serde(bound(
142 serialize = "Term: serde::Serialize, W: serde::Serialize",
143 deserialize = "Term: serde::Deserialize<'de> + Eq + std::hash::Hash, W: serde::Deserialize<'de>"
144 ))
145)]
146struct Segment<Term, W: Weight = u32> {
147 postings: HashMap<Term, Vec<(DocId, W)>>,
149 doc_len: HashMap<DocId, u32>,
151 doc_terms: HashMap<DocId, Vec<Term>>,
153}
154
155impl<Term, W: Weight> Default for Segment<Term, W> {
156 fn default() -> Self {
157 Self {
158 postings: HashMap::new(),
159 doc_len: HashMap::new(),
160 doc_terms: HashMap::new(),
161 }
162 }
163}
164
165#[derive(Debug)]
171#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
172#[cfg_attr(
173 feature = "serde",
174 serde(bound(
175 serialize = "Term: serde::Serialize, W: serde::Serialize",
176 deserialize = "Term: serde::Deserialize<'de> + Eq + std::hash::Hash, W: serde::Deserialize<'de>"
177 ))
178)]
179pub struct PostingsIndex<Term = String, W: Weight = u32> {
180 segments: Vec<Segment<Term, W>>,
181 doc_segment: HashMap<DocId, usize>,
183 doc_len: HashMap<DocId, u32>,
185 df: HashMap<Term, u32>,
187 total_doc_len: u64,
188}
189
190impl<Term, W: Weight> Default for PostingsIndex<Term, W> {
191 fn default() -> Self {
192 Self {
193 segments: Vec::new(),
194 doc_segment: HashMap::new(),
195 doc_len: HashMap::new(),
196 df: HashMap::new(),
197 total_doc_len: 0,
198 }
199 }
200}
201
202impl<Term, W: Weight> PostingsIndex<Term, W>
203where
204 Term: Clone + Eq + Hash + Ord,
205{
206 pub fn new() -> Self {
208 Self::default()
209 }
210
211 pub fn num_docs(&self) -> u32 {
213 self.doc_len.len() as u32
214 }
215
216 pub fn avg_doc_len(&self) -> f32 {
218 let n = self.num_docs() as f32;
219 if n == 0.0 {
220 return 0.0;
221 }
222 (self.total_doc_len as f32) / n
223 }
224
225 pub fn document_ids(&self) -> impl Iterator<Item = DocId> + '_ {
227 self.doc_len.keys().copied()
228 }
229
230 pub fn document_len(&self, doc_id: DocId) -> u32 {
232 self.doc_len.get(&doc_id).copied().unwrap_or(0)
233 }
234
235 pub fn df<Q>(&self, term: &Q) -> u32
237 where
238 Term: Borrow<Q>,
239 Q: Hash + Eq + ?Sized,
240 {
241 self.df.get(term).copied().unwrap_or(0)
242 }
243
244 pub fn terms(&self) -> impl Iterator<Item = &Term> + '_ {
246 self.df.keys()
247 }
248
249 pub fn add_weighted_document(
260 &mut self,
261 doc_id: DocId,
262 weighted_terms: &[(Term, W)],
263 ) -> Result<(), Error> {
264 if self.doc_segment.contains_key(&doc_id) {
265 return Err(Error::DuplicateDocId(doc_id));
266 }
267
268 let mut term_weights: HashMap<Term, W> = HashMap::new();
269 let mut doc_length: u64 = 0;
270 for (t, w) in weighted_terms {
271 term_weights
272 .entry(t.clone())
273 .and_modify(|existing| existing.accumulate(*w))
274 .or_insert(*w);
275 doc_length += w.to_doc_len();
276 }
277
278 let mut doc_terms: Vec<Term> = term_weights.keys().cloned().collect();
279 doc_terms.sort_unstable();
280
281 let mut seg = Segment::<Term, W>::default();
283 seg.doc_len.insert(doc_id, doc_length as u32);
284 seg.doc_terms.insert(doc_id, doc_terms.clone());
285 for (term, w) in term_weights {
286 seg.postings.entry(term).or_default().push((doc_id, w));
287 }
288 for postings in seg.postings.values_mut() {
290 postings.sort_unstable_by_key(|(id, _)| *id);
291 }
292
293 let seg_idx = self.segments.len();
294 self.segments.push(seg);
295 self.doc_segment.insert(doc_id, seg_idx);
296 self.doc_len.insert(doc_id, doc_length as u32);
297 self.total_doc_len += doc_length;
298
299 for term in doc_terms {
301 *self.df.entry(term).or_insert(0) += 1;
302 }
303
304 Ok(())
305 }
306}
307
308impl<Term> PostingsIndex<Term, u32>
310where
311 Term: Clone + Eq + Hash + Ord,
312{
313 pub fn add_document(&mut self, doc_id: DocId, terms: &[Term]) -> Result<(), Error> {
318 let weighted: Vec<(Term, u32)> = terms.iter().map(|t| (t.clone(), 1u32)).collect();
319 self.add_weighted_document(doc_id, &weighted)
320 }
321}
322
323impl<Term, W: Weight> PostingsIndex<Term, W>
324where
325 Term: Clone + Eq + Hash + Ord,
326{
327 pub fn delete_document(&mut self, doc_id: DocId) -> bool {
331 let seg_idx = match self.doc_segment.remove(&doc_id) {
332 Some(i) => i,
333 None => return false,
334 };
335 let doc_len = self.doc_len.remove(&doc_id).unwrap_or(0);
336 self.total_doc_len = self.total_doc_len.saturating_sub(doc_len as u64);
337
338 let seg = &self.segments[seg_idx];
339 if let Some(terms) = seg.doc_terms.get(&doc_id) {
340 for term in terms {
341 if let Some(df) = self.df.get_mut(term) {
342 *df = df.saturating_sub(1);
343 if *df == 0 {
344 self.df.remove(term);
345 }
346 }
347 }
348 }
349 true
350 }
351
352 pub fn term_frequency<Q>(&self, doc_id: DocId, term: &Q) -> W
358 where
359 Term: Borrow<Q>,
360 Q: Hash + Eq + ?Sized,
361 {
362 let seg_idx = match self.doc_segment.get(&doc_id) {
363 Some(i) => *i,
364 None => return W::zero(),
365 };
366 let seg = &self.segments[seg_idx];
367 let postings = match seg.postings.get(term) {
368 Some(p) => p,
369 None => return W::zero(),
370 };
371 match postings.binary_search_by_key(&doc_id, |(id, _)| *id) {
372 Ok(i) => postings[i].1,
373 Err(_) => W::zero(),
374 }
375 }
376
377 pub fn candidates<Q>(&self, query_terms: &[Q]) -> Vec<DocId>
379 where
380 Term: Borrow<Q>,
381 Q: Hash + Eq,
382 {
383 if query_terms.is_empty() {
384 return Vec::new();
385 }
386 let mut out: Vec<DocId> = Vec::new();
387 let mut seen: HashSet<DocId> = HashSet::new();
388 for term in query_terms {
389 for (doc_id, _) in self.postings_iter(term) {
390 if seen.insert(doc_id) {
391 out.push(doc_id);
392 }
393 }
394 }
395 out.sort_unstable();
397 out
398 }
399
400 pub fn candidates_all_terms<Q>(&self, query_terms: &[Q]) -> Vec<DocId>
410 where
411 Term: Borrow<Q>,
412 Q: Hash + Eq,
413 {
414 if query_terms.is_empty() {
415 return Vec::new();
416 }
417
418 let mut uniq: Vec<&Q> = Vec::new();
420 let mut seen: HashSet<&Q> = HashSet::new();
421 for t in query_terms {
422 if seen.insert(t) {
423 uniq.push(t);
424 }
425 }
426 if uniq.is_empty() {
427 return Vec::new();
428 }
429
430 uniq.sort_by_key(|t| self.df(*t));
433 if self.df(uniq[0]) == 0 {
434 return Vec::new();
435 }
436
437 let mut acc: Vec<DocId> = self.postings_iter(uniq[0]).map(|(id, _)| id).collect();
438 acc.sort_unstable();
439
440 for &t in uniq.iter().skip(1) {
441 if self.df(t) == 0 {
442 return Vec::new();
443 }
444 let mut docs: Vec<DocId> = self.postings_iter(t).map(|(id, _)| id).collect();
445 docs.sort_unstable();
446 acc = intersect_sorted(&acc, &docs);
447 if acc.is_empty() {
448 break;
449 }
450 }
451 acc
452 }
453
454 pub fn plan_candidates<Q>(&self, query_terms: &[Q], cfg: PlannerConfig) -> CandidatePlan
460 where
461 Term: Borrow<Q>,
462 Q: Hash + Eq,
463 {
464 if query_terms.is_empty() {
465 return CandidatePlan::Candidates(Vec::new());
466 }
467
468 let n = self.num_docs();
469 if n == 0 {
470 return CandidatePlan::Candidates(Vec::new());
471 }
472
473 let mut seen_terms: HashSet<&Q> = HashSet::new();
475 let mut df_sum: u64 = 0;
476 for t in query_terms {
477 if !seen_terms.insert(t) {
478 continue;
479 }
480 df_sum = df_sum.saturating_add(self.df(t) as u64);
481 if df_sum >= cfg.max_candidates as u64 {
482 return CandidatePlan::ScanAll;
483 }
484 }
485
486 let ratio = (df_sum as f32) / (n as f32);
487 if ratio > cfg.max_candidate_ratio {
488 return CandidatePlan::ScanAll;
489 }
490
491 CandidatePlan::Candidates(self.candidates(query_terms))
492 }
493
494 pub fn postings_iter<'a, Q>(&'a self, term: &'a Q) -> impl Iterator<Item = (DocId, W)> + 'a
496 where
497 Term: Borrow<Q>,
498 Q: Hash + Eq + ?Sized,
499 {
500 self.segments.iter().flat_map(move |seg| {
501 seg.postings
502 .get(term)
503 .into_iter()
504 .flat_map(|v| v.iter().copied())
505 .filter(|(doc_id, _)| self.doc_segment.contains_key(doc_id))
506 })
507 }
508
509 #[cfg(feature = "persistence")]
511 pub fn save<D: durability::Directory + ?Sized>(
512 &self,
513 dir: &D,
514 path: &str,
515 ) -> Result<(), Box<dyn std::error::Error>>
516 where
517 Term: serde::Serialize,
518 W: serde::Serialize,
519 {
520 let bytes = postcard::to_allocvec(self)?;
521 dir.atomic_write(path, &bytes)?;
522 Ok(())
523 }
524
525 #[cfg(feature = "persistence")]
532 pub fn save_durable<D: durability::Directory + ?Sized>(
533 &self,
534 dir: &D,
535 path: &str,
536 ) -> Result<(), Box<dyn std::error::Error>>
537 where
538 Term: serde::Serialize,
539 W: serde::Serialize,
540 {
541 let bytes = postcard::to_allocvec(self)?;
542 dir.atomic_write_durable(path, &bytes)?;
543 Ok(())
544 }
545
546 #[cfg(feature = "persistence")]
548 pub fn load<D: durability::Directory + ?Sized>(
549 dir: &D,
550 path: &str,
551 ) -> Result<Self, Box<dyn std::error::Error>>
552 where
553 for<'de> Term: serde::Deserialize<'de>,
554 for<'de> W: serde::Deserialize<'de>,
555 {
556 use std::io::Read;
557
558 let mut f = dir.open_file(path)?;
559 let mut bytes = Vec::new();
560 f.read_to_end(&mut bytes)?;
561 let idx: Self = postcard::from_bytes(&bytes)?;
562 Ok(idx)
563 }
564}
565
566fn intersect_sorted(a: &[DocId], b: &[DocId]) -> Vec<DocId> {
567 let mut out = Vec::new();
568 let mut i = 0usize;
569 let mut j = 0usize;
570 while i < a.len() && j < b.len() {
571 let x = a[i];
572 let y = b[j];
573 if x == y {
574 out.push(x);
575 i += 1;
576 j += 1;
577 } else if x < y {
578 i += 1;
579 } else {
580 j += 1;
581 }
582 }
583 out
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use proptest::prelude::*;
590
591 #[test]
592 fn add_and_lookup_basic() {
593 let mut idx: PostingsIndex<String> = PostingsIndex::new();
594 idx.add_document(
595 0,
596 &[
597 String::from("the"),
598 String::from("quick"),
599 String::from("quick"),
600 ],
601 )
602 .unwrap();
603 assert_eq!(idx.num_docs(), 1);
604 assert_eq!(idx.document_len(0), 3);
605 assert_eq!(idx.df("quick"), 1);
606 assert_eq!(idx.term_frequency(0, "quick"), 2);
607 assert_eq!(idx.term_frequency(0, "missing"), 0);
608 }
609
610 #[test]
611 fn delete_updates_df() {
612 let mut idx: PostingsIndex<String> = PostingsIndex::new();
613 idx.add_document(0, &[String::from("a"), String::from("b")])
614 .unwrap();
615 idx.add_document(1, &[String::from("b"), String::from("c")])
616 .unwrap();
617 assert_eq!(idx.df("b"), 2);
618 assert!(idx.delete_document(0));
619 assert_eq!(idx.df("b"), 1);
620 assert_eq!(idx.df("a"), 0);
621 assert_eq!(idx.term_frequency(0, "b"), 0);
622 assert_eq!(idx.term_frequency(1, "b"), 1);
623 }
624
625 #[test]
626 fn multilingual_terms_do_not_panic() {
627 let mut idx: PostingsIndex<String> = PostingsIndex::new();
628 idx.add_document(
629 0,
630 &[
631 String::from("Müller"), String::from("東京"), String::from("مرحبا"), String::from("Москва"), String::from("cafe\u{0301}"), String::from("👨\u{200D}👩\u{200D}👧\u{200D}👦"), ],
638 )
639 .unwrap();
640 assert_eq!(idx.num_docs(), 1);
641 assert_eq!(idx.df("東京"), 1);
642 assert_eq!(idx.term_frequency(0, "مرحبا"), 1);
643 }
644
645 proptest::proptest! {
646 #[test]
647 fn df_is_never_negative_and_upper_bounded(
648 docs in proptest::collection::vec(
649 proptest::collection::vec("[a-z]{1,6}", 0..20),
650 0..50
651 )
652 ) {
653 use proptest::prelude::*;
654 let mut idx: PostingsIndex<String> = PostingsIndex::new();
655 for (i, doc) in docs.iter().enumerate() {
656 let terms: Vec<String> = doc.to_vec();
657 idx.add_document(i as u32, &terms).unwrap();
658 }
659 let n = idx.num_docs();
660 for t in idx.terms() {
661 let df = idx.df(t);
662 prop_assert!(df <= n);
663 }
664 }
665 }
666
667 proptest! {
668 #[test]
669 fn candidates_have_no_false_negatives(
670 docs in prop::collection::vec(
671 prop::collection::vec("[a-z]{1,6}", 0..20),
672 0..30
673 ),
674 query in prop::collection::vec("[a-z]{1,6}", 0..10),
675 ) {
676 let mut idx: PostingsIndex<String> = PostingsIndex::new();
677 for (i, terms) in docs.iter().enumerate() {
678 let terms: Vec<String> = terms.to_vec();
679 idx.add_document(i as DocId, &terms).unwrap();
680 }
681
682 let q_terms: Vec<String> = query.to_vec();
683 let cands = idx.candidates(&q_terms);
684 let cand_set: std::collections::HashSet<DocId> = cands.into_iter().collect();
685
686 for doc_id in idx.document_ids() {
688 let mut hits = false;
689 for t in &q_terms {
690 if idx.term_frequency(doc_id, t) > 0 {
691 hits = true;
692 break;
693 }
694 }
695 if hits {
696 prop_assert!(cand_set.contains(&doc_id));
697 }
698 }
699 }
700 }
701
702 #[test]
703 fn planner_can_bail_out() {
704 let mut idx: PostingsIndex<String> = PostingsIndex::new();
705 for i in 0..100u32 {
707 idx.add_document(i, &["common".to_string(), format!("u{i}")])
708 .unwrap();
709 }
710 let cfg = PlannerConfig {
711 max_candidate_ratio: 0.2,
712 max_candidates: 10,
713 };
714 let plan = idx.plan_candidates(&["common".to_string()], cfg);
715 assert_eq!(plan, CandidatePlan::ScanAll);
716 }
717
718 #[test]
719 fn generic_term_type_u32_works() {
720 let mut idx: PostingsIndex<u32> = PostingsIndex::new();
722 idx.add_document(0, &[1, 2, 2, 3]).unwrap();
723 idx.add_document(1, &[2, 4]).unwrap();
724 assert_eq!(idx.df(&2u32), 2);
725 assert_eq!(idx.term_frequency(0, &2u32), 2);
726 assert_eq!(idx.term_frequency(1, &2u32), 1);
727
728 let plan = idx.plan_candidates(
731 &[2u32],
732 PlannerConfig {
733 max_candidate_ratio: 1.0,
734 max_candidates: 10_000,
735 },
736 );
737 match plan {
738 CandidatePlan::Candidates(cands) => {
739 assert!(cands.contains(&0));
740 assert!(cands.contains(&1));
741 }
742 CandidatePlan::ScanAll => panic!("unexpected bailout for tiny corpus"),
743 }
744 }
745
746 #[test]
747 fn candidates_all_terms_intersects() {
748 let mut idx: PostingsIndex<String> = PostingsIndex::new();
749 idx.add_document(0, &["a".into(), "b".into(), "b".into()])
750 .unwrap();
751 idx.add_document(1, &["a".into(), "c".into()]).unwrap();
752 idx.add_document(2, &["b".into(), "c".into()]).unwrap();
753
754 assert_eq!(
755 idx.candidates_all_terms(&["a".to_string(), "b".to_string()]),
756 vec![0]
757 );
758 assert_eq!(
759 idx.candidates_all_terms(&["b".to_string(), "c".to_string()]),
760 vec![2]
761 );
762 assert!(idx
763 .candidates_all_terms(&["missing".to_string()])
764 .is_empty());
765 }
766
767 #[test]
768 fn candidates_are_sorted_and_unique() {
769 let mut idx: PostingsIndex<String> = PostingsIndex::new();
770 idx.add_document(2, &["a".into(), "b".into()]).unwrap();
771 idx.add_document(1, &["a".into()]).unwrap();
772 idx.add_document(3, &["b".into()]).unwrap();
773
774 let c = idx.candidates(&["b".to_string(), "a".to_string()]);
775 assert_eq!(c, vec![1, 2, 3]);
776 }
777
778 proptest! {
779 #[test]
780 fn candidates_all_terms_have_no_false_negatives(
781 docs in prop::collection::vec(
782 prop::collection::vec("[a-z]{1,6}", 0..20),
783 0..30
784 ),
785 query in prop::collection::vec("[a-z]{1,6}", 0..10),
786 ) {
787 let mut idx: PostingsIndex<String> = PostingsIndex::new();
788 for (i, terms) in docs.iter().enumerate() {
789 let terms: Vec<String> = terms.to_vec();
790 idx.add_document(i as DocId, &terms).unwrap();
791 }
792
793 let q_terms: Vec<String> = query.to_vec();
794 let cands = idx.candidates_all_terms(&q_terms);
795 let cand_set: std::collections::HashSet<DocId> = cands.into_iter().collect();
796
797 let mut uniq: std::collections::HashSet<&String> = std::collections::HashSet::new();
800 for t in &q_terms {
801 uniq.insert(t);
802 }
803 for doc_id in idx.document_ids() {
804 let mut ok = !uniq.is_empty();
805 for t in &uniq {
806 if idx.term_frequency(doc_id, t.as_str()) == 0 {
807 ok = false;
808 break;
809 }
810 }
811 if ok {
812 prop_assert!(cand_set.contains(&doc_id));
813 }
814 }
815 }
816 }
817
818 proptest! {
819 #[test]
820 fn plan_candidates_candidates_respects_thresholds(
821 docs in prop::collection::vec(
822 prop::collection::vec("[a-z]{1,6}", 0..30),
823 0..60
824 ),
825 query in prop::collection::vec("[a-z]{1,6}", 0..12),
826 max_ratio in 0.05f32..1.0f32,
827 max_abs in 1u32..5000u32,
828 ) {
829 let mut idx: PostingsIndex<String> = PostingsIndex::new();
830 for (i, doc) in docs.iter().enumerate() {
831 let terms: Vec<String> = doc.to_vec();
832 idx.add_document(i as DocId, &terms).unwrap();
833 }
834
835 let q_terms: Vec<String> = query.to_vec();
836 let cfg = PlannerConfig { max_candidate_ratio: max_ratio, max_candidates: max_abs };
837
838 let plan = idx.plan_candidates(&q_terms, cfg);
839 if let CandidatePlan::Candidates(_cands) = plan {
840 let n = idx.num_docs();
843 if n == 0 || q_terms.is_empty() {
844 return Ok(());
845 }
846
847 let mut seen: std::collections::HashSet<&String> = std::collections::HashSet::new();
848 let mut df_sum: u64 = 0;
849 for t in &q_terms {
850 if !seen.insert(t) { continue; }
851 df_sum = df_sum.saturating_add(idx.df(t) as u64);
852 }
853
854 prop_assert!(df_sum < (cfg.max_candidates as u64));
855 prop_assert!((df_sum as f32) / (n as f32) <= cfg.max_candidate_ratio);
856 }
857 }
858 }
859
860 #[test]
863 fn float_weighted_index_basic() {
864 let mut idx: PostingsIndex<String, f32> = PostingsIndex::new();
866 idx.add_weighted_document(
867 0,
868 &[
869 (String::from("neural"), 0.42),
870 (String::from("network"), 0.87),
871 (String::from("deep"), 0.15),
872 ],
873 )
874 .unwrap();
875
876 assert_eq!(idx.num_docs(), 1);
877 assert!((idx.term_frequency(0, "neural") - 0.42).abs() < 1e-6);
878 assert!((idx.term_frequency(0, "network") - 0.87).abs() < 1e-6);
879 assert!((idx.term_frequency(0, "missing") - 0.0).abs() < 1e-6);
880 }
881
882 #[test]
883 fn float_weighted_candidates() {
884 let mut idx: PostingsIndex<String, f32> = PostingsIndex::new();
885 idx.add_weighted_document(0, &[(String::from("cat"), 0.9), (String::from("dog"), 0.3)])
886 .unwrap();
887 idx.add_weighted_document(
888 1,
889 &[(String::from("dog"), 0.8), (String::from("fish"), 0.5)],
890 )
891 .unwrap();
892
893 let cands = idx.candidates(&[String::from("dog")]);
895 assert_eq!(cands.len(), 2);
896
897 let cands = idx.candidates(&[String::from("cat")]);
899 assert_eq!(cands, vec![0]);
900
901 assert_eq!(idx.df("dog"), 2);
903 assert_eq!(idx.df("cat"), 1);
904 }
905
906 #[test]
907 fn float_weighted_delete() {
908 let mut idx: PostingsIndex<String, f32> = PostingsIndex::new();
909 idx.add_weighted_document(0, &[(String::from("a"), 0.5)])
910 .unwrap();
911 idx.add_weighted_document(1, &[(String::from("a"), 0.8)])
912 .unwrap();
913
914 assert_eq!(idx.df("a"), 2);
915 idx.delete_document(0);
916 assert_eq!(idx.df("a"), 1);
917 assert_eq!(idx.num_docs(), 1);
918 }
919
920 #[test]
921 fn float_weighted_accumulates_duplicates() {
922 let mut idx: PostingsIndex<String, f32> = PostingsIndex::new();
924 idx.add_weighted_document(
925 0,
926 &[(String::from("term"), 0.3), (String::from("term"), 0.4)],
927 )
928 .unwrap();
929
930 assert!((idx.term_frequency(0, "term") - 0.7).abs() < 1e-6);
932 }
933
934 #[test]
935 fn classic_u32_still_works_unchanged() {
936 let mut idx: PostingsIndex<String> = PostingsIndex::new();
938 idx.add_document(0, &[String::from("hello"), String::from("hello")])
939 .unwrap();
940 assert_eq!(idx.term_frequency(0, "hello"), 2);
942 assert_eq!(idx.document_len(0), 2); }
944}