Skip to main content

ipfrs_semantic/
corpus_indexer.rs

1//! Inverted-index corpus indexer with BM25 scoring and faceted filtering.
2//!
3//! [`CorpusIndexer`] builds an in-memory inverted index over a set of
4//! [`IndexedDocument`]s, scores queries with BM25 (k1 = 1.5, b = 0.75), and
5//! supports arbitrary key-value facet filters.
6
7use std::collections::{HashMap, HashSet};
8
9// ── Error ─────────────────────────────────────────────────────────────────────
10
11/// Errors returned by [`CorpusIndexer`].
12#[derive(Debug, Clone, PartialEq)]
13pub enum IndexError {
14    /// A document with this id was already present in the index.
15    DocumentAlreadyExists(String),
16    /// No document with this id was found.
17    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// ── Core data types ───────────────────────────────────────────────────────────
36
37/// A document stored in the corpus index.
38#[derive(Debug, Clone)]
39pub struct IndexedDocument {
40    /// Unique identifier for this document.
41    pub doc_id: String,
42    /// Full text content to be indexed.
43    pub content: String,
44    /// Arbitrary metadata key-value pairs (author, date, category, tags, …).
45    pub fields: HashMap<String, String>,
46    /// Optional vector embedding for hybrid search.
47    pub embedding: Option<Vec<f64>>,
48    /// Unix timestamp (seconds) when the document was indexed.
49    pub indexed_at: u64,
50}
51
52impl IndexedDocument {
53    /// Create a new [`IndexedDocument`] with the given id and content.
54    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    /// Set a metadata field, returning `self` for chaining.
65    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    /// Attach an embedding vector, returning `self` for chaining.
71    pub fn with_embedding(mut self, emb: Vec<f64>) -> Self {
72        self.embedding = Some(emb);
73        self
74    }
75
76    /// Set `indexed_at`, returning `self` for chaining.
77    pub fn with_indexed_at(mut self, ts: u64) -> Self {
78        self.indexed_at = ts;
79        self
80    }
81}
82
83/// An entry in the postings list for one (term, document) pair.
84#[derive(Debug, Clone)]
85pub struct PostingEntry {
86    /// Document identifier.
87    pub doc_id: String,
88    /// Number of times the term appears in the document.
89    pub term_freq: u32,
90    /// Word-index positions of each term occurrence (0-based).
91    pub positions: Vec<u32>,
92}
93
94/// The core inverted index data structure.
95#[derive(Debug, Clone, Default)]
96pub struct InvertedIndex {
97    /// Maps each term to the list of documents that contain it.
98    pub term_to_postings: HashMap<String, Vec<PostingEntry>>,
99    /// Maps each term to the number of documents that contain it.
100    pub doc_freq: HashMap<String, u32>,
101    /// Total number of indexed documents.
102    pub total_docs: u32,
103    /// Average document length (in tokens, excluding stopwords).
104    pub avg_doc_length: f64,
105}
106
107/// A ranked result returned by [`CorpusIndexer::search`].
108#[derive(Debug, Clone)]
109pub struct SearchResult {
110    /// Document identifier.
111    pub doc_id: String,
112    /// BM25 score.
113    pub score: f64,
114    /// Query terms that matched this document.
115    pub matched_terms: Vec<String>,
116    /// Optional context snippet around the first matching term.
117    pub snippet: Option<String>,
118}
119
120/// A key-value filter applied to document metadata fields.
121#[derive(Debug, Clone)]
122pub struct FacetFilter {
123    /// Metadata field name.
124    pub field: String,
125    /// Required exact value.
126    pub value: String,
127}
128
129impl FacetFilter {
130    /// Construct a new facet filter.
131    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/// A search query submitted to [`CorpusIndexer::search`].
140#[derive(Debug, Clone)]
141pub struct IndexQuery {
142    /// Terms to search for (will be tokenized/normalised internally).
143    pub terms: Vec<String>,
144    /// Facet filters that must all be satisfied.
145    pub facets: Vec<FacetFilter>,
146    /// Maximum number of results to return.
147    pub top_k: usize,
148    /// Minimum BM25 score threshold.
149    pub min_score: f64,
150    /// If `true`, only documents that contain *all* terms are returned (AND).
151    /// If `false`, documents containing *any* term are returned (OR).
152    pub require_all_terms: bool,
153}
154
155impl IndexQuery {
156    /// Construct a simple keyword query.
157    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/// Aggregate index statistics.
169#[derive(Debug, Clone)]
170pub struct IndexStats {
171    /// Number of documents currently in the index.
172    pub doc_count: usize,
173    /// Number of unique terms in the vocabulary.
174    pub vocabulary_size: usize,
175    /// Average document length (tokens after stopword removal).
176    pub avg_doc_length: f64,
177    /// Total number of (term, document) posting entries.
178    pub total_postings: usize,
179}
180
181// ── Tokenisation helpers ──────────────────────────────────────────────────────
182
183/// Tokenise `text`: lowercase, split on non-alphanumeric, remove stopwords, drop empty tokens.
184fn 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
197/// Return the default English stopword set.
198fn 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
209// ── BM25 ──────────────────────────────────────────────────────────────────────
210
211const BM25_K1: f64 = 1.5;
212const BM25_B: f64 = 0.75;
213
214/// Compute the IDF component of BM25.
215///
216/// `idf(t) = ln((N - df + 0.5) / (df + 0.5) + 1)`
217#[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/// Compute the TF saturation component of BM25.
225///
226/// `tf_sat(t, d) = tf * (k1 + 1) / (tf + k1 * (1 - b + b * dl / avg_dl))`
227#[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
234// ── CorpusIndexer ─────────────────────────────────────────────────────────────
235
236/// In-memory inverted index with BM25 scoring and faceted filtering.
237///
238/// # Example
239///
240/// ```rust
241/// use ipfrs_semantic::corpus_indexer::{CorpusIndexer, IndexedDocument, IndexQuery};
242///
243/// let mut indexer = CorpusIndexer::new();
244///
245/// let doc = IndexedDocument::new("doc1", "Rust is a systems programming language");
246/// indexer.add_document(doc).unwrap();
247///
248/// let query = IndexQuery::new(["rust", "programming"]);
249/// let results = indexer.search(&query);
250/// assert!(!results.is_empty());
251/// assert_eq!(results[0].doc_id, "doc1");
252/// ```
253pub struct CorpusIndexer {
254    /// Core inverted index.
255    pub index: InvertedIndex,
256    /// Original documents keyed by doc_id.
257    pub documents: HashMap<String, IndexedDocument>,
258    /// Set of words that are ignored during tokenisation.
259    pub stopwords: HashSet<String>,
260    /// Per-document token length cache (tokens after stopword removal).
261    doc_lengths: HashMap<String, u32>,
262}
263
264impl Default for CorpusIndexer {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270impl CorpusIndexer {
271    // ── Construction ──────────────────────────────────────────────────────
272
273    /// Create a new [`CorpusIndexer`] initialised with default English stopwords.
274    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    // ── Mutation ──────────────────────────────────────────────────────────
284
285    /// Index a new document.
286    ///
287    /// # Errors
288    ///
289    /// Returns [`IndexError::DocumentAlreadyExists`] if a document with the
290    /// same `doc_id` has already been indexed.
291    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    /// Remove a document from the index.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`IndexError::DocumentNotFound`] if no document with that id exists.
306    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    /// Replace an existing document with an updated version.
318    ///
319    /// Internally performs `remove_document` followed by `add_document`.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`IndexError::DocumentNotFound`] if the document did not exist.
324    pub fn update_document(&mut self, doc: IndexedDocument) -> Result<(), IndexError> {
325        let id = doc.doc_id.clone();
326        self.remove_document(&id)?;
327        // After removal the id no longer exists, so add_document will succeed.
328        self.add_document(doc)
329            .map_err(|_| IndexError::DocumentNotFound(id))
330    }
331
332    // ── Search ────────────────────────────────────────────────────────────
333
334    /// Execute a [`IndexQuery`] against the index.
335    ///
336    /// Steps:
337    /// 1. Normalise query terms (lowercase + stopword removal).
338    /// 2. Find candidate documents using the inverted index.
339    /// 3. Apply AND / OR logic.
340    /// 4. Apply facet filters.
341    /// 5. Compute BM25 scores.
342    /// 6. Apply `min_score` threshold.
343    /// 7. Attach snippets.
344    /// 8. Sort by score descending and return `top_k` results.
345    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        // Normalise query terms — remove stopwords and lowercase.
351        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        // For each (doc_id) accumulate BM25 score and track matched terms.
372        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        // AND filter: require all normalised terms to match.
403        if query.require_all_terms {
404            scores.retain(|_, (_, matched)| norm_terms.iter().all(|t| matched.contains(t)));
405        }
406
407        // Facet filtering.
408        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        // Apply min_score, build results, attach snippets.
424        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        // Sort by score descending, then doc_id ascending for determinism.
439        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    // ── Inspection ────────────────────────────────────────────────────────
451
452    /// Return a context snippet from `doc_id` centered on the first occurrence
453    /// of any of `terms`.
454    ///
455    /// `window` is the number of words on each side of the matched word.
456    /// Returns `None` if the document is not found or no term matches.
457    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        // Find the first position where any normalised query term appears.
462        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            // Strip punctuation from word edges for matching purposes.
467            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    /// Term frequency of `term` inside `doc_id` (0 if not found).
477    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    /// Number of documents that contain `term`.
488    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    /// Number of documents currently indexed.
494    pub fn doc_count(&self) -> usize {
495        self.documents.len()
496    }
497
498    /// Number of distinct terms in the vocabulary.
499    pub fn vocabulary_size(&self) -> usize {
500        self.index.term_to_postings.len()
501    }
502
503    /// Top `n` terms by document frequency (descending).
504    ///
505    /// Ties are broken alphabetically.
506    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    /// Return the doc_ids of all documents that have `fields[field] == value`.
519    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    /// Aggregate statistics about the current state of the index.
533    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    // ── Private helpers ───────────────────────────────────────────────────
544
545    /// Add `doc` to the inverted index and length cache.
546    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        // Build term → positions map for this document.
553        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            // Update postings list.
564            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            // Update document frequency.
574            *self.index.doc_freq.entry(term).or_insert(0) += 1;
575        }
576    }
577
578    /// Remove `doc_id` from all postings lists, cleaning up empty entries.
579    fn unindex_document(&mut self, doc_id: &str) {
580        // Collect affected terms first to avoid borrowing issues.
581        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                    // Decrement df.
597                    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    /// Recalculate and store `avg_doc_length` from `doc_lengths`.
607    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// ── Tests ─────────────────────────────────────────────────────────────────────
618
619#[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    // ── Helper ──────────────────────────────────────────────────────────────
627
628    fn make_doc(id: &str, content: &str) -> IndexedDocument {
629        IndexedDocument::new(id, content)
630    }
631
632    // ── Tokenisation ────────────────────────────────────────────────────────
633
634    #[test]
635    fn tokenize_basic() {
636        let sw = default_stopwords();
637        let tokens = tokenize("Hello, world! This is a test.", &sw);
638        // "this", "is", "a" are stopwords
639        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    // ── BM25 maths ──────────────────────────────────────────────────────────
676
677    #[test]
678    fn bm25_idf_positive_for_rare_term() {
679        // A term that appears in 1 out of 100 docs should have positive IDF.
680        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        // Large TF should not grow unboundedly.
701        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    // ── add_document ────────────────────────────────────────────────────────
706
707    #[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    // ── remove_document ─────────────────────────────────────────────────────
755
756    #[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        // Posting lists should be gone.
783        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        // "rust" is still present via d2.
796        assert!(idx.index.term_to_postings.contains_key("rust"));
797        assert_eq!(idx.document_frequency("rust"), 1);
798    }
799
800    // ── update_document ─────────────────────────────────────────────────────
801
802    #[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    // ── term_frequency / document_frequency ─────────────────────────────────
825
826    #[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    // ── vocabulary_size / doc_count ─────────────────────────────────────────
867
868    #[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    // ── top_terms ───────────────────────────────────────────────────────────
883
884    #[test]
885    fn top_terms_returns_by_df_descending() {
886        let mut idx = CorpusIndexer::new();
887        // "rust" appears in d1, d2, d3 → df = 3
888        // "programming" appears only in d1 → df = 1
889        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    // ── documents_with_field ────────────────────────────────────────────────
910
911    #[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    // ── snippet ─────────────────────────────────────────────────────────────
944
945    #[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        // "rust" is the first word — window should not panic.
982        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    // ── search ──────────────────────────────────────────────────────────────
989
990    #[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        // d1: "rust" appears 3 times → higher TF.
1074        idx.add_document(make_doc("d1", "rust rust rust systems"))
1075            .expect("test: add_document should succeed");
1076        // d2: "rust" appears 1 time.
1077        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; // unreachably high
1095        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    // ── stats ────────────────────────────────────────────────────────────────
1124
1125    #[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    // ── PostingEntry construction ───────────────────────────────────────────
1151
1152    #[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    // ── IndexedDocument builder ─────────────────────────────────────────────
1164
1165    #[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    // ── Edge cases ───────────────────────────────────────────────────────────
1182
1183    #[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        // Query in uppercase should still match.
1189        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}