Skip to main content

microscope_memory/
text_index.rs

1//! Inverted text index (TIX1) for fast `find_text` / recall lexical prefilter.
2//!
3//! The hot search paths scan every block (`find` ~51 ms, `recall` ~255 ms on
4//! ~1M blocks). This index maps lowercased words to the blocks that contain
5//! them, so a query only touches the blocks that can possibly match, then the
6//! caller re-runs its exact predicate on those candidates (identical results,
7//! far fewer text scans).
8//!
9//! Binary layout (mmap, zero heap):
10//! ```text
11//!   [0..4)   magic "TIX1"
12//!   [4..8)   word_count  u32
13//!   [8..12)  total_postings u32
14//!   [12..16) block_count u32
15//!   [16..20) postings_offset u32 (absolute byte offset; 4-aligned)
16//!   [20..)   dict offsets: word_count × u32 (absolute byte offset of a DictEntry)
17//!   DictEntry (variable):
18//!     u16 word_len + word bytes + u32 posting_start + u32 posting_len
19//!   padding to 4-byte alignment
20//!   postings: total_postings × u32 block ids (each list ascending)
21//! ```
22//!
23//! Tokenization is lowercased alphanumeric runs of at least 2 chars. Postings
24//! are block ids in ascending order (blocks are visited ascending at build
25//! time), so membership checks use binary search.
26
27use std::collections::HashMap;
28use std::fs;
29use std::path::Path;
30
31const MAGIC: &[u8; 4] = b"TIX1";
32const HEADER_SIZE: usize = 20; // magic(4) + word_count(4) + total_postings(4) + block_count(4) + postings_offset(4)
33
34/// Mmap-backed inverted text index.
35pub struct TextIndex {
36    data: memmap2::Mmap,
37    word_count: usize,
38    total_postings: usize,
39    block_count: usize,
40    postings_start: usize,
41}
42
43struct DictEntry<'a> {
44    word: &'a str,
45    posting_start: usize,
46    posting_len: usize,
47}
48
49/// Split text into lowercased alphanumeric words (length >= 2).
50/// `find_text` semantics are substring-based, so query tokens are treated the
51/// same way here and candidates are later verified with the exact predicate.
52pub fn tokenize(text: &str) -> Vec<String> {
53    let mut words = Vec::new();
54    let mut cur = String::new();
55    for ch in text.chars() {
56        if ch.is_alphanumeric() {
57            cur.extend(ch.to_lowercase());
58        } else {
59            if cur.chars().count() >= 2 {
60                words.push(std::mem::take(&mut cur));
61            } else {
62                cur.clear();
63            }
64        }
65    }
66    if cur.chars().count() >= 2 {
67        words.push(cur);
68    }
69    words
70}
71
72impl TextIndex {
73    /// Open an existing text_index.bin (sparse validation; corrupt files return
74    /// `None` so callers fall back to a full scan).
75    pub fn open(path: &Path) -> Option<Self> {
76        if !path.exists() {
77            return None;
78        }
79        let file = fs::File::open(path).ok()?;
80        let data = unsafe { memmap2::Mmap::map(&file).ok()? };
81        if data.len() < HEADER_SIZE || &data[0..4] != MAGIC {
82            return None;
83        }
84        let word_count = u32_at(&data, 4)? as usize;
85        let total_postings = u32_at(&data, 8)? as usize;
86        let block_count = u32_at(&data, 12)? as usize;
87        let postings_start = u32_at(&data, 16)? as usize;
88        let offsets_end = HEADER_SIZE.checked_add(word_count.checked_mul(4)?)?;
89        let postings_bytes = total_postings.checked_mul(4)?;
90        if postings_start < offsets_end
91            || data.len() < postings_start.checked_add(postings_bytes)?
92            || !postings_start.is_multiple_of(4)
93        {
94            return None;
95        }
96        Some(TextIndex {
97            data,
98            word_count,
99            total_postings,
100            block_count,
101            postings_start,
102        })
103    }
104
105    /// Number of blocks the index was built over.
106    pub fn block_count(&self) -> usize {
107        self.block_count
108    }
109
110    /// Number of dictionary words (distinct tokens) in the index.
111    pub fn word_count_public(&self) -> usize {
112        self.word_count
113    }
114
115    fn u32_at(&self, off: usize) -> Option<u32> {
116        self.data
117            .get(off..off + 4)
118            .map(|s| u32::from_le_bytes(s.try_into().unwrap()))
119    }
120
121    fn u16_at(&self, off: usize) -> Option<u16> {
122        self.data
123            .get(off..off + 2)
124            .map(|s| u16::from_le_bytes(s.try_into().unwrap()))
125    }
126
127    /// Read the i-th dictionary entry.
128    fn entry(&self, i: usize) -> Option<DictEntry<'_>> {
129        if i >= self.word_count {
130            return None;
131        }
132        let entry_off = self.u32_at(HEADER_SIZE + i * 4)? as usize;
133        let wlen = self.u16_at(entry_off)? as usize;
134        let start = entry_off.checked_add(2)?;
135        let end = start.checked_add(wlen)?;
136        let word = std::str::from_utf8(self.data.get(start..end)?).ok()?;
137        let ps = self.u32_at(end)? as usize;
138        let pl = self.u32_at(end + 4)? as usize;
139        if ps.checked_add(pl)? > self.total_postings {
140            return None;
141        }
142        Some(DictEntry {
143            word,
144            posting_start: ps,
145            posting_len: pl,
146        })
147    }
148
149    /// Posting slice for a dictionary entry (zero-copy mmap).
150    fn postings(&self, e: &DictEntry<'_>) -> &[u32] {
151        let start = self.postings_start + e.posting_start * 4;
152        let ptr = self.data[start..].as_ptr() as *const u32;
153        // Safety: bounds validated in entry() against total_postings; the
154        // postings region is a multiple of 4 bytes past a 4-aligned base.
155        unsafe { std::slice::from_raw_parts(ptr, e.posting_len) }
156    }
157
158    /// Binary search the sorted word dictionary.
159    fn find_word(&self, target: &str) -> Option<usize> {
160        let mut lo = 0usize;
161        let mut hi = self.word_count;
162        while lo < hi {
163            let mid = (lo + hi) / 2;
164            match self.entry(mid)?.word.cmp(target) {
165                std::cmp::Ordering::Less => lo = mid + 1,
166                std::cmp::Ordering::Greater => hi = mid,
167                std::cmp::Ordering::Equal => return Some(mid),
168            }
169        }
170        None
171    }
172
173    /// Range of dictionary entries whose word starts with `prefix` (sorted →
174    /// contiguous). Returns [lo, hi) dict indices.
175    fn range_words_starting_with(&self, prefix: &str) -> (usize, usize) {
176        let mut lo = 0usize;
177        let mut hi = self.word_count;
178        while lo < hi {
179            let mid = (lo + hi) / 2;
180            if let Some(e) = self.entry(mid) {
181                if e.word < prefix {
182                    lo = mid + 1;
183                } else {
184                    hi = mid;
185                }
186            } else {
187                break;
188            }
189        }
190        let start = lo;
191        hi = self.word_count;
192        while lo < hi {
193            let mid = (lo + hi) / 2;
194            if let Some(e) = self.entry(mid) {
195                if e.word.starts_with(prefix) {
196                    lo = mid + 1;
197                } else {
198                    hi = mid;
199                }
200            } else {
201                break;
202            }
203        }
204        (start, lo)
205    }
206
207    /// Blocks whose text contains `token` as a substring of some word.
208    ///
209    /// A pure-alphanumeric token appears in a block iff some word of that block
210    /// contains it, so the union of postings of every dictionary word containing
211    /// the token is exactly the matching block set. (This is what makes
212    /// `find_text`'s substring semantics exact even for fragments like "rus"
213    /// inside "rust".)
214    fn blocks_containing_token(&self, token: &str) -> Vec<u32> {
215        let mut out: Vec<u32> = Vec::new();
216        for i in 0..self.word_count {
217            let Some(e) = self.entry(i) else { break };
218            if e.word.contains(token) {
219                out.extend_from_slice(self.postings(&e));
220            }
221        }
222        out.sort_unstable();
223        out.dedup();
224        out
225    }
226
227    /// Exact superset of blocks whose text contains `query` as a substring.
228    ///
229    /// Returns `None` when the query cannot be routed through the index (too
230    /// short / punctuation-only) — caller must fall back to a full scan.
231    /// Returns `Some(ids)` with ids sorted ascending; the caller verifies the
232    /// exact substring predicate on the candidates.
233    pub fn candidates_substring(&self, query_lower: &str) -> Option<Vec<u32>> {
234        let tokens = tokenize(query_lower);
235        if tokens.is_empty() {
236            return None;
237        }
238        let mut sets: Vec<Vec<u32>> = Vec::with_capacity(tokens.len());
239        for t in &tokens {
240            let set = self.blocks_containing_token(t);
241            if set.is_empty() {
242                return Some(Vec::new());
243            }
244            sets.push(set);
245        }
246        // Intersection (a block must contain every token for the phrase).
247        sets.sort_by_key(|v| v.len());
248        let mut result = std::mem::take(&mut sets[0]);
249        for other in &sets[1..] {
250            if result.is_empty() {
251                break;
252            }
253            result.retain(|&id| other.binary_search(&id).is_ok());
254        }
255        Some(result)
256    }
257
258    /// Blocks matching any token under `token_similarity` semantics:
259    /// `t == q` for short tokens (3-4 bytes), or sharing the first 5 chars
260    /// for tokens of length >= 5. Used as a recall prefilter; `None` → full
261    /// scan fallback.
262    pub fn candidates_lexical(&self, tokens: &[String]) -> Option<Vec<u32>> {
263        if tokens.is_empty() {
264            return None;
265        }
266        let mut union: Vec<u32> = Vec::new();
267        for t in tokens {
268            let chars: Vec<char> = t.chars().collect();
269            if chars.len() < 3 {
270                continue;
271            }
272            if chars.len() < 5 {
273                // Short token: only exact word equality matches.
274                if let Some(i) = self.find_word(t) {
275                    if let Some(e) = self.entry(i) {
276                        union.extend_from_slice(self.postings(&e));
277                    }
278                }
279                continue;
280            }
281            // Token length >= 5: every dictionary word sharing the first 5
282            // chars can produce a lexical match (exact, prefix-extension, and
283            // divergence-after-5 all covered by the contiguous range).
284            let prefix: String = chars[..5].iter().collect();
285            let (lo, hi) = self.range_words_starting_with(&prefix);
286            for i in lo..hi {
287                if let Some(e) = self.entry(i) {
288                    union.extend_from_slice(self.postings(&e));
289                }
290            }
291        }
292        union.sort_unstable();
293        union.dedup();
294        Some(union)
295    }
296
297    /// Like [`TextIndex::candidates_lexical`], but keeps only blocks that at
298    /// least `min_matches` *distinct query terms* matched.
299    ///
300    /// The unbounded union is unusable on a large corpus. A five-character token
301    /// expands to every dictionary word sharing those five characters and
302    /// contributes its whole posting list, and on the SciFact index a query
303    /// about diabetes returned 83,703 blocks -- 90% of the query's time went
304    /// into scoring blocks that matched one incidental word.
305    ///
306    /// Counting distinct matched terms is the cheapest useful bound available
307    /// here, because the postings are already being walked: it adds an
308    /// increment per posting instead of a second pass, and a block that matched
309    /// four of the query's terms is a far better candidate than one that matched
310    /// the single most common word in the corpus.
311    ///
312    /// `min_matches == 1` returns the same set as `candidates_lexical`, and a
313    /// query with no tokens still returns `None`, so this is a strict
314    /// generalisation rather than a second behaviour. Callers are still expected
315    /// to fall back to the vector side when the result is empty -- the lexical
316    /// index is a prefilter, not a gate.
317    pub fn candidates_lexical_min_matches(
318        &self,
319        tokens: &[String],
320        min_matches: u32,
321    ) -> Option<Vec<u32>> {
322        if tokens.is_empty() {
323            return None;
324        }
325        if min_matches <= 1 {
326            return self.candidates_lexical(tokens);
327        }
328
329        // How many distinct query terms matched each block. A term is counted
330        // once per query token even if several dictionary words in its
331        // expansion hit the same block, otherwise a prefix group would inflate
332        // its own count.
333        let mut counts: HashMap<u32, u32> = HashMap::new();
334        for t in tokens {
335            let chars: Vec<char> = t.chars().collect();
336            if chars.len() < 3 {
337                continue;
338            }
339            let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
340            if chars.len() < 5 {
341                if let Some(i) = self.find_word(t) {
342                    if let Some(e) = self.entry(i) {
343                        seen.extend(self.postings(&e).iter().copied());
344                    }
345                }
346            } else {
347                let prefix: String = chars[..5].iter().collect();
348                let (lo, hi) = self.range_words_starting_with(&prefix);
349                for i in lo..hi {
350                    if let Some(e) = self.entry(i) {
351                        seen.extend(self.postings(&e).iter().copied());
352                    }
353                }
354            }
355            for block in seen {
356                *counts.entry(block).or_insert(0) += 1;
357            }
358        }
359
360        let mut kept: Vec<u32> = counts
361            .into_iter()
362            .filter(|(_, c)| *c >= min_matches)
363            .map(|(b, _)| b)
364            .collect();
365        kept.sort_unstable();
366        Some(kept)
367    }
368}
369
370fn u32_at(data: &[u8], off: usize) -> Option<u32> {
371    data.get(off..off + 4)
372        .map(|s| u32::from_le_bytes(s.try_into().unwrap()))
373}
374
375/// Build a TIX1 index from block texts. `block_count` is the total number of
376/// blocks (used for staleness checks at open time).
377pub fn build_text_index<'a, I>(
378    texts: I,
379    block_count: usize,
380    output_path: &Path,
381) -> Result<(), String>
382where
383    I: IntoIterator<Item = &'a str>,
384{
385    // Word → block ids. Blocks are visited in ascending order, so each
386    // posting list is naturally ascending.
387    let mut map: HashMap<String, Vec<u32>> = HashMap::new();
388    let mut total_postings = 0usize;
389    for (i, text) in texts.into_iter().enumerate() {
390        let mut words = tokenize(text);
391        words.sort_unstable();
392        words.dedup();
393        for w in words {
394            total_postings += 1;
395            map.entry(w).or_default().push(i as u32);
396        }
397    }
398
399    let mut words: Vec<(&String, &Vec<u32>)> = map.iter().collect();
400    words.sort_by(|a, b| a.0.cmp(b.0));
401
402    let mut buf = Vec::with_capacity(HEADER_SIZE + words.len() * 4 + total_postings * 4);
403    buf.extend_from_slice(MAGIC);
404    buf.extend_from_slice(&(words.len() as u32).to_le_bytes());
405    buf.extend_from_slice(&(total_postings as u32).to_le_bytes());
406    buf.extend_from_slice(&(block_count as u32).to_le_bytes());
407    // postings_offset placeholder; patched after the entries are laid out and
408    // the buffer is padded to 4-byte alignment (postings are read as f32-slice
409    // aliases, so they must be 4-aligned relative to the mmap base).
410    buf.extend_from_slice(&0u32.to_le_bytes());
411
412    let offsets_pos = buf.len();
413    buf.resize(buf.len() + words.len() * 4, 0);
414
415    let mut posting_cursor = 0usize;
416    let mut postings_buf: Vec<u8> = Vec::with_capacity(total_postings * 4);
417    for (wi, (w, list)) in words.iter().enumerate() {
418        let entry_off = buf.len();
419        let off = offsets_pos + wi * 4;
420        buf[off..off + 4].copy_from_slice(&(entry_off as u32).to_le_bytes());
421        let wb = w.as_bytes();
422        buf.extend_from_slice(&(wb.len() as u16).to_le_bytes());
423        buf.extend_from_slice(wb);
424        buf.extend_from_slice(&(posting_cursor as u32).to_le_bytes());
425        buf.extend_from_slice(&(list.len() as u32).to_le_bytes());
426        for &id in list.iter() {
427            postings_buf.extend_from_slice(&id.to_le_bytes());
428        }
429        posting_cursor += list.len();
430    }
431    // Pad so the postings region starts on a 4-byte boundary.
432    while buf.len() % 4 != 0 {
433        buf.push(0);
434    }
435    let postings_offset = buf.len() as u32;
436    buf[16..20].copy_from_slice(&postings_offset.to_le_bytes());
437    buf.extend_from_slice(&postings_buf);
438
439    let tmp_path = output_path.with_extension("bin.tmp");
440    fs::write(&tmp_path, &buf).map_err(|e| format!("write text_index.bin: {e}"))?;
441    fs::rename(&tmp_path, output_path).map_err(|e| format!("rename text_index.bin: {e}"))?;
442    Ok(())
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    fn build_from(texts: &[&str]) -> (tempfile::TempDir, TextIndex) {
450        let dir = tempfile::tempdir().unwrap();
451        let path = dir.path().join("text_index.bin");
452        build_text_index(texts.iter().copied(), texts.len(), &path).unwrap();
453        let idx = TextIndex::open(&path).unwrap();
454        (dir, idx)
455    }
456
457    #[test]
458    fn min_matches_of_one_is_exactly_the_unbounded_union() {
459        // The default is 1, so the default must be the old behaviour and not a
460        // near miss of it. Anything else would silently change ranking for
461        // every existing deployment the first time this shipped.
462        let texts = [
463            "the user has a cat named bella",
464            "cats and dogs are both common pets",
465            "gastric bypass treats type two diabetes",
466            "diabetes mellitus is a chronic disease",
467            "bypass surgery has risks",
468            "unrelated text about gardening tools",
469        ];
470        let (_dir, idx) = build_from(&texts);
471        let queries: Vec<Vec<String>> = vec![
472            vec!["cat".into()],
473            vec!["diabetes".into()],
474            vec!["bypass".into()],
475            vec!["diabetes".into(), "bypass".into()],
476            vec!["the".into(), "cat".into(), "named".into()],
477            vec!["missingword".into()],
478            vec![],
479        ];
480        for tokens in &queries {
481            let old = idx.candidates_lexical(tokens);
482            let bounded = idx.candidates_lexical_min_matches(tokens, 1);
483            assert_eq!(old, bounded, "tokens {tokens:?} must be identical at 1");
484            // 0 and 1 both mean "no bound" rather than "keep nothing".
485            assert_eq!(idx.candidates_lexical_min_matches(tokens, 0), old);
486        }
487    }
488
489    #[test]
490    fn min_matches_narrows_the_set_without_ever_widening_it() {
491        // Block 1 must contain only one of the two terms. An earlier version of
492        // this fixture had "bypass" in both, which made the bound a no-op and
493        // failed for a reason that had nothing to do with the code.
494        let texts = [
495            "gastric bypass treats type two diabetes mellitus",
496            "diabetes is mentioned here but surgery is not",
497            "gastric sleeve is a different operation entirely",
498            "gardening tools have nothing to do with the above",
499        ];
500        let (_dir, idx) = build_from(&texts);
501        let tokens = vec!["diabetes".to_string(), "bypass".to_string()];
502
503        let all = idx.candidates_lexical_min_matches(&tokens, 1).unwrap();
504        assert_eq!(all, vec![0, 1], "the union is both matching blocks");
505        let both = idx.candidates_lexical_min_matches(&tokens, 2).unwrap();
506        assert_eq!(both, vec![0], "only block 0 contains both terms");
507        for b in &both {
508            assert!(all.contains(b), "{b} must be a subset of the union");
509        }
510        // A threshold above the token count can legitimately return nothing;
511        // the caller is responsible for not treating that as an error.
512        let impossible = idx.candidates_lexical_min_matches(&tokens, 5).unwrap();
513        assert!(impossible.is_empty());
514    }
515
516    #[test]
517    fn tokenize_basic() {
518        assert_eq!(tokenize("Rust is fast!"), vec!["rust", "is", "fast"]);
519        assert_eq!(tokenize("a b"), Vec::<String>::new());
520        assert_eq!(tokenize("rust-mmap"), vec!["rust", "mmap"]);
521        assert_eq!(tokenize(""), Vec::<String>::new());
522        // Hungarian unicode lowercase.
523        assert_eq!(tokenize("ÁRvíZ"), vec!["árvíz"]);
524    }
525
526    #[test]
527    fn roundtrip_and_substring_candidates() {
528        let (_dir, idx) = build_from(&[
529            "Rust is fast",
530            "Memory is managed by the runtime",
531            "rust zooms across depths",
532        ]);
533        assert_eq!(idx.block_count(), 3);
534
535        // Exact word.
536        let c = idx.candidates_substring("rust").unwrap();
537        assert_eq!(c, vec![0, 2]);
538
539        // Word in two blocks, intersection with another word.
540        let c = idx.candidates_substring("rust is").unwrap();
541        assert_eq!(c, vec![0]);
542
543        // Fragment inside a longer word (substring semantics).
544        let c = idx.candidates_substring("rus").unwrap();
545        assert_eq!(c, vec![0, 2]);
546
547        // Missing word → empty, not fallback.
548        assert_eq!(
549            idx.candidates_substring("xyzzy").unwrap(),
550            Vec::<u32>::new()
551        );
552
553        // Too short → not indexable.
554        assert!(idx.candidates_substring("a").is_none());
555    }
556
557    #[test]
558    fn lexical_candidates_match_token_similarity() {
559        use crate::relevance::RelevanceQuery;
560        let texts = [
561            "rusting is a memory effect",
562            "rusti prefix of rusting",
563            "rust never sleeps",
564            "quick brown fox",
565        ];
566        let (_dir, idx) = build_from(&texts);
567
568        // Query "rusting": matches exact token (0), shared-5-prefix "rusti" (1).
569        // "rust" (block 2) is only 4 chars — token_similarity requires the
570        // shorter side to have length >= 5, so it does NOT match "rusting".
571        let q = RelevanceQuery::new("rusting");
572        let cands = idx.candidates_lexical(q.tokens()).unwrap();
573        assert_eq!(cands, vec![0, 1]);
574
575        // Short token (len 3-4): only exact equality.
576        let q = RelevanceQuery::new("rust");
577        let cands = idx.candidates_lexical(q.tokens()).unwrap();
578        assert_eq!(cands, vec![2]);
579    }
580
581    #[test]
582    fn missing_or_empty_lexical() {
583        let (_dir, idx) = build_from(&["one two three"]);
584        let cands = idx.candidates_lexical(&["zzz".to_string()]).unwrap();
585        assert!(cands.is_empty());
586        assert!(idx.candidates_lexical(&[]).is_none());
587    }
588
589    #[test]
590    fn truncated_or_corrupt_file_fails_open() {
591        let dir = tempfile::tempdir().unwrap();
592        let path = dir.path().join("text_index.bin");
593        build_text_index(["hello world"].iter().copied(), 1, &path).unwrap();
594        let bytes = fs::read(&path).unwrap();
595        // Truncate in the middle of postings.
596        fs::write(&path, &bytes[..bytes.len() - 3]).unwrap();
597        assert!(TextIndex::open(&path).is_none());
598        // Bad magic.
599        fs::write(&path, b"NOPE").unwrap();
600        assert!(TextIndex::open(&path).is_none());
601    }
602
603    #[test]
604    fn scale_search_is_selective() {
605        // 50k synthetic blocks, one unique term each. The index must return
606        // only the matching block(s), not scan everything.
607        let mut texts: Vec<String> = Vec::with_capacity(50_000);
608        for i in 0..50_000u32 {
609            texts.push(format!("unique_term_{i} common filler text"));
610        }
611        let owned: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
612        let dir = tempfile::tempdir().unwrap();
613        let path = dir.path().join("text_index.bin");
614        build_text_index(owned.iter().copied(), owned.len(), &path).unwrap();
615        let idx = TextIndex::open(&path).unwrap();
616
617        // 49999 is the maximum id, so no other token contains it as a
618        // substring — the candidate set is exactly one block.
619        let c = idx.candidates_substring("unique_term_49999").unwrap();
620        assert_eq!(c.len(), 1);
621        assert_eq!(c[0], 49999);
622        // Substring semantics: "4242" also appears inside 14242/24242/34242/
623        // 44242 and 42420..42429, but candidates stay tiny vs 50k blocks.
624        let c = idx.candidates_substring("unique_term_4242").unwrap();
625        assert!(c.contains(&4242), "matching block must be a candidate");
626        assert!(
627            c.len() <= 15,
628            "candidates must be selective, got {}",
629            c.len()
630        );
631        // Common word hits all blocks.
632        let c = idx.candidates_substring("common").unwrap();
633        assert_eq!(c.len(), 50_000);
634    }
635}