Skip to main content

postings/
lib.rs

1//! # postings
2//!
3//! A small inverted-index core built around postings lists.
4//!
5//! ## Scope (deliberate)
6//!
7//! - This crate is **index-only**: it does not store document content.
8//! - It supports **candidate generation** with **no false negatives** (a caller may
9//!   choose to verify candidates with an exact matcher).
10//! - It uses a Lucene-style mental model: immutable "segments" (here: append-only
11//!   batches) and logical deletes.
12//!
13//! ## Optional modules
14//!
15//! - `postings::codec`: low-level codecs (varint/gap) for postings payloads (in this repo).
16//! - `postings::positional` (feature `positional`): positional postings for phrase/proximity evaluation.
17//!
18//! ## Non-goals (for now)
19//!
20//! - On-disk persistence / compaction
21//! - Rich query language beyond "union of term postings"
22
23#![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
34/// Document identifier.
35pub type DocId = u32;
36
37/// Trait for term weight types in posting lists.
38///
39/// `u32` is the default for classical term frequency counts.
40/// `f32` enables learned sparse representations (SPLADE, SPLADE++, etc.)
41/// where terms carry continuous weights rather than integer frequencies.
42pub trait Weight: Copy + Default + std::fmt::Debug + 'static {
43    /// The zero/absent weight.
44    fn zero() -> Self;
45    /// Accumulate (add) a weight into self.
46    fn accumulate(&mut self, other: Self);
47    /// Convert to f32 for scoring.
48    fn to_f32(self) -> f32;
49    /// Convert to u64 for total doc length accumulation.
50    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        // For float weights, doc length is the count of non-zero terms
88        // (not the sum of weights), keeping avg_doc_len meaningful for BM25
89        1
90    }
91}
92
93/// Errors returned by `postings`.
94#[derive(thiserror::Error, Debug)]
95pub enum Error {
96    /// The caller attempted to add an already-present document id.
97    #[error("document already exists: {0}")]
98    DuplicateDocId(DocId),
99}
100
101/// Planner output for candidate generation.
102///
103/// This is the simplest encoding of the key invariant:
104/// indexing is allowed to bail out (and fall back to scanning) when a query is too broad.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum CandidatePlan {
107    /// Use the returned candidates as a search set.
108    Candidates(Vec<DocId>),
109    /// Bail out: the query is too broad, so the caller should scan all documents.
110    ScanAll,
111}
112
113/// Configuration for candidate planning / bailout.
114#[derive(Debug, Clone, Copy)]
115pub struct PlannerConfig {
116    /// If an upper bound on candidates exceeds this ratio of the corpus, bail out.
117    ///
118    /// Note: we estimate using \(\sum_t df(t)\), which is an *upper bound* (can exceed N).
119    pub max_candidate_ratio: f32,
120    /// If an upper bound on candidates exceeds this absolute count, bail out.
121    pub max_candidates: u32,
122}
123
124impl Default for PlannerConfig {
125    fn default() -> Self {
126        Self {
127            // Conservative defaults: useful for "index as filter" use-cases.
128            max_candidate_ratio: 0.6,
129            max_candidates: 200_000,
130        }
131    }
132}
133
134/// Immutable postings for a batch of documents.
135///
136/// A "segment" here is an append-only batch that is never mutated after creation.
137#[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    /// term -> sorted postings list of (doc_id, weight)
148    postings: HashMap<Term, Vec<(DocId, W)>>,
149    /// doc_id -> doc length in terms
150    doc_len: HashMap<DocId, u32>,
151    /// doc_id -> unique terms in that doc (for df adjustments on delete)
152    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/// A postings-based inverted index with segment-style updates.
166///
167/// This is an in-memory MVP:
168/// - each `add_document` creates a new (small) segment
169/// - deletes are logical (we update global stats; segment remains immutable)
170#[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    /// live doc -> segment index
182    doc_segment: HashMap<DocId, usize>,
183    /// live doc -> length
184    doc_len: HashMap<DocId, u32>,
185    /// term -> df (number of live documents containing term)
186    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    /// Create an empty postings index.
207    pub fn new() -> Self {
208        Self::default()
209    }
210
211    /// Count of live documents currently indexed.
212    pub fn num_docs(&self) -> u32 {
213        self.doc_len.len() as u32
214    }
215
216    /// Average document length (in terms) over live documents.
217    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    /// Iterate live document ids.
226    pub fn document_ids(&self) -> impl Iterator<Item = DocId> + '_ {
227        self.doc_len.keys().copied()
228    }
229
230    /// Document length (in terms). Returns 0 for unknown doc ids.
231    pub fn document_len(&self, doc_id: DocId) -> u32 {
232        self.doc_len.get(&doc_id).copied().unwrap_or(0)
233    }
234
235    /// Document frequency for a term (count of live documents containing term).
236    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    /// Iterate all distinct terms present in live documents.
245    pub fn terms(&self) -> impl Iterator<Item = &Term> + '_ {
246        self.df.keys()
247    }
248
249    /// Add a document by doc id and weighted term pairs.
250    ///
251    /// Each `(term, weight)` pair represents a term and its weight in the document.
252    /// For classical indexing, weight is the term frequency (u32).
253    /// For learned sparse retrieval (SPLADE), weight is a continuous value (f32).
254    ///
255    /// Duplicate terms are accumulated (weights summed).
256    ///
257    /// If `doc_id` already exists, return an error. Call `delete_document` first
258    /// to model updates as delete+add (segment-style).
259    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        // Build an immutable segment for this doc.
282        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        // Ensure postings lists are sorted (future-proof for multi-doc segments).
289        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        // Update global df.
300        for term in doc_terms {
301            *self.df.entry(term).or_insert(0) += 1;
302        }
303
304        Ok(())
305    }
306}
307
308/// Backward-compatible methods for integer-weighted (classical) postings.
309impl<Term> PostingsIndex<Term, u32>
310where
311    Term: Clone + Eq + Hash + Ord,
312{
313    /// Add a document by doc id and term stream (classical indexing).
314    ///
315    /// Terms are counted to produce integer term frequencies.
316    /// For weighted terms (SPLADE), use [`add_weighted_document`](PostingsIndex::add_weighted_document).
317    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    /// Logically delete a document (if present).
328    ///
329    /// Returns true if the doc existed.
330    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    /// Term weight (frequency) of `term` in `doc_id`.
353    ///
354    /// Returns `W::zero()` if the term is not present or the doc is unknown.
355    /// For classical indexes (`W = u32`), this is the term frequency count.
356    /// For learned sparse indexes (`W = f32`), this is the SPLADE weight.
357    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    /// Candidate documents that contain at least one query term.
378    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        // Deterministic output: stable ascending doc ids.
396        out.sort_unstable();
397        out
398    }
399
400    /// Candidate documents that contain **all** query terms (conjunctive / AND).
401    ///
402    /// This is a common first step before:
403    /// - exact phrase/proximity verification (positional index), or
404    /// - scoring (BM25/TF-IDF) in a higher layer.
405    ///
406    /// Notes:
407    /// - Duplicate terms in `query_terms` are treated as a single requirement.
408    /// - Results are returned in sorted order.
409    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        // Deduplicate query terms.
419        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        // DAAT-style intersection over sorted doc-id lists.
431        // Anchor on the rarest term to minimize intermediate sets.
432        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    /// Plan candidate generation, with a bailout option for broad queries.
455    ///
456    /// Returns:
457    /// - `CandidatePlan::Candidates` when the query is selective enough.
458    /// - `CandidatePlan::ScanAll` when the query is too broad (caller should scan all docs).
459    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        // Upper bound on candidate count: sum df(t) over unique terms.
474        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    /// Iterate postings for a term across all segments (live docs only).
495    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    /// Save the index to a directory using `durability`.
510    #[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    /// Save the index with stable-storage durability barriers.
526    ///
527    /// This is strictly stronger than `save()` on filesystem-backed directories:
528    /// it fsyncs the temp file and syncs the parent directory after the atomic rename.
529    ///
530    /// For non-filesystem backends (e.g. `MemoryDirectory`) this returns `NotSupported`.
531    #[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    /// Load the index from a directory using `durability`.
547    #[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"),                           // Latin + diacritics
632                String::from("東京"),                             // CJK
633                String::from("مرحبا"),                            // Arabic (RTL)
634                String::from("Москва"),                           // Cyrillic
635                String::from("cafe\u{0301}"),                     // NFD combining mark
636                String::from("👨\u{200D}👩\u{200D}👧\u{200D}👦"), // emoji ZWJ sequence
637            ],
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 every live doc, if it contains at least one query term, it must appear in candidates().
687            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        // Make a very common term.
706        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        // Smoke test that the generic `Term` machinery isn't String-only.
721        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        // With the default bailout config, a term that appears in all docs is allowed to
729        // trigger ScanAll. For this smoke test, use a permissive config.
730        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            // For every live doc, if it contains *all* query terms (at least once), it must appear.
798            // (Duplicates in the query are treated as a single requirement.)
799            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                // If we didn't bail out, then our computed df upper-bound must be below
841                // BOTH bailout thresholds (by construction).
842                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    // ── Float-weighted (SPLADE) tests ─────────────────────────────────
861
862    #[test]
863    fn float_weighted_index_basic() {
864        // SPLADE-style: terms with continuous weights
865        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        // Query for "dog" -- both docs have it
894        let cands = idx.candidates(&[String::from("dog")]);
895        assert_eq!(cands.len(), 2);
896
897        // Query for "cat" -- only doc 0
898        let cands = idx.candidates(&[String::from("cat")]);
899        assert_eq!(cands, vec![0]);
900
901        // df
902        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        // If same term appears twice, weights should accumulate
923        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        // 0.3 + 0.4 = 0.7
931        assert!((idx.term_frequency(0, "term") - 0.7).abs() < 1e-6);
932    }
933
934    #[test]
935    fn classic_u32_still_works_unchanged() {
936        // Verify the default u32 path is backward compatible
937        let mut idx: PostingsIndex<String> = PostingsIndex::new();
938        idx.add_document(0, &[String::from("hello"), String::from("hello")])
939            .unwrap();
940        // "hello" appears twice -> tf=2
941        assert_eq!(idx.term_frequency(0, "hello"), 2);
942        assert_eq!(idx.document_len(0), 2); // 2 terms total
943    }
944}