Skip to main content

rete_core/
text_index.rs

1//! Full-text (whole-word) index over literals — `token → subjects`.
2//!
3//! Built at `rete build --text-index` and stored as the optional `TextIndex`
4//! file section (SPEC §6). Every string-literal object is tokenized into words;
5//! the index maps each word to the **sorted subject ids** that carry it, so a
6//! reader can answer "which entities mention `glucose`?" without scanning the
7//! literals — and a *remote* reader fetches only the posting lists it queries.
8//!
9//! On-disk section layout:
10//! ```text
11//! varint  token_table_len
12//! token table (compressed with the file's block codec):
13//!   varint num_tokens
14//!   per token (sorted): varint shared_prefix_len   # front-coded vs previous token
15//!                       varint suffix_len, suffix bytes
16//!                       varint posting_off, varint posting_len   # into the postings blob
17//! postings blob (uncompressed, so a single posting range-reads directly):
18//!   per token (same order): varint count, then `count` delta-varint subject ids
19//! ```
20//! The token table is small (distinct words) and read whole; the postings blob is
21//! the bulk and is fetched one posting at a time on the remote path.
22
23use std::collections::{BTreeMap, BTreeSet};
24
25use crate::file::{compress, decompress, FileError};
26use crate::varint::{read_uvarint, write_uvarint};
27
28/// Minimum token length kept (1-char words are low-value and bloat the index).
29const MIN_TOKEN_LEN: usize = 2;
30
31/// Most table tokens a [`TextIndex::substring`] piece may match before the
32/// lookup declines (each match is a posting read — a range fetch remotely).
33const SUBSTRING_TOKEN_CAP: usize = 512;
34
35/// Split text into index/query tokens: Unicode-alphanumeric runs, lowercased,
36/// length ≥ `MIN_TOKEN_LEN`. The build and query sides MUST use this same
37/// function so a query word matches how it was indexed.
38pub fn tokenize(text: &str) -> impl Iterator<Item = String> + '_ {
39    text.split(|c: char| !c.is_alphanumeric())
40        .filter(|t| t.chars().count() >= MIN_TOKEN_LEN)
41        .map(|t| t.to_lowercase())
42}
43
44/// Length of the longest shared **byte** prefix of `a` and `b`, clamped down to a
45/// char boundary of `b` so front-coded suffixes stay valid UTF-8.
46fn shared_prefix(a: &str, b: &str) -> usize {
47    let mut n = a
48        .as_bytes()
49        .iter()
50        .zip(b.as_bytes())
51        .take_while(|(x, y)| x == y)
52        .count();
53    while n > 0 && !b.is_char_boundary(n) {
54        n -= 1;
55    }
56    n
57}
58
59/// Accumulates `token → subjects` and serializes the section.
60#[derive(Default)]
61pub struct TextIndexBuilder {
62    postings: BTreeMap<String, BTreeSet<u32>>,
63}
64
65impl TextIndexBuilder {
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Record that `subject` carries `token`.
71    pub fn add(&mut self, token: &str, subject: u32) {
72        self.postings
73            .entry(token.to_string())
74            .or_default()
75            .insert(subject);
76    }
77
78    /// Tokenize `text` and record every word for `subject`.
79    pub fn add_text(&mut self, text: &str, subject: u32) {
80        for tok in tokenize(text) {
81            self.postings.entry(tok).or_default().insert(subject);
82        }
83    }
84
85    pub fn is_empty(&self) -> bool {
86        self.postings.is_empty()
87    }
88
89    /// Serialize the section (deterministic: tokens sorted, subjects ascending).
90    pub fn build(&self, codec: u8) -> Vec<u8> {
91        // Postings blob + per-token (offset, len) into it.
92        let mut postings = Vec::new();
93        let mut entries: Vec<(&str, u64, u64)> = Vec::with_capacity(self.postings.len());
94        for (token, subjects) in &self.postings {
95            let off = postings.len() as u64;
96            write_uvarint(&mut postings, subjects.len() as u64);
97            let mut prev = 0u32;
98            for &s in subjects {
99                write_uvarint(&mut postings, (s - prev) as u64);
100                prev = s;
101            }
102            entries.push((token, off, postings.len() as u64 - off));
103        }
104        // Front-coded token table.
105        let mut tt = Vec::new();
106        write_uvarint(&mut tt, entries.len() as u64);
107        let mut prev = "";
108        for (token, off, len) in &entries {
109            let shared = shared_prefix(prev, token);
110            write_uvarint(&mut tt, shared as u64);
111            let suffix = &token.as_bytes()[shared..];
112            write_uvarint(&mut tt, suffix.len() as u64);
113            tt.extend_from_slice(suffix);
114            write_uvarint(&mut tt, *off);
115            write_uvarint(&mut tt, *len);
116            prev = token;
117        }
118        let ctt = compress(codec, &tt);
119
120        let mut out = Vec::with_capacity(10 + ctt.len() + postings.len());
121        write_uvarint(&mut out, ctt.len() as u64);
122        out.extend_from_slice(&ctt);
123        out.extend_from_slice(&postings);
124        out
125    }
126}
127
128/// Where a [`TextIndex`] reads posting lists from.
129enum Postings {
130    /// The whole postings blob, resident (local / fully-loaded reads).
131    Resident(Vec<u8>),
132    /// Fetch one posting `(offset_within_blob, len)` on demand (remote reads).
133    Remote(Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>),
134}
135
136/// A parsed text index: the token table (always resident) plus a posting source.
137pub struct TextIndex {
138    /// `(token, posting_offset, posting_len)`, sorted by token.
139    tokens: Vec<(String, u64, u64)>,
140    postings: Postings,
141}
142
143impl TextIndex {
144    /// Parse a whole section (local): token table + the full postings blob resident.
145    pub fn from_section(section: &[u8], codec: u8) -> Result<Self, FileError> {
146        let (tokens, postings_start) = parse_token_table(section, codec)?;
147        let postings = section
148            .get(postings_start..)
149            .ok_or(FileError::Container("text-index postings overrun"))?
150            .to_vec();
151        Ok(TextIndex {
152            tokens,
153            postings: Postings::Resident(postings),
154        })
155    }
156
157    /// Build from a section **prefix** holding the token table, plus a loader that
158    /// fetches a posting `(offset_within_postings_blob, len)` on demand (remote).
159    pub fn from_token_table(
160        prefix: &[u8],
161        codec: u8,
162        loader: Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>,
163    ) -> Result<Self, FileError> {
164        let (tokens, _postings_start) = parse_token_table(prefix, codec)?;
165        Ok(TextIndex {
166            tokens,
167            postings: Postings::Remote(loader),
168        })
169    }
170
171    /// Byte offset of the postings blob within the section (token-table end).
172    /// The remote opener uses this to base its posting-range loader.
173    pub fn postings_base(section_prefix: &[u8]) -> Option<usize> {
174        let (len, n) = read_uvarint(section_prefix)?;
175        Some(n + len as usize)
176    }
177
178    pub fn token_count(&self) -> usize {
179        self.tokens.len()
180    }
181
182    fn posting_for(&self, off: u64, len: u64) -> Option<Vec<u8>> {
183        match &self.postings {
184            Postings::Resident(blob) => blob
185                .get(off as usize..(off + len) as usize)
186                .map(<[u8]>::to_vec),
187            Postings::Remote(load) => load(off, len),
188        }
189    }
190
191    /// Subjects whose literals contain the exact `token` (case-insensitive — the
192    /// caller passes a [`tokenize`]d word), sorted ascending.
193    pub fn lookup(&self, token: &str) -> Vec<u32> {
194        let i = self.tokens.partition_point(|(t, _, _)| t.as_str() < token);
195        match self.tokens.get(i) {
196            Some((t, off, len)) if t == token => self
197                .posting_for(*off, *len)
198                .map(|b| decode_postings(&b))
199                .unwrap_or_default(),
200            _ => Vec::new(),
201        }
202    }
203
204    /// Subjects whose literals contain a word that CONTAINS `piece` as a
205    /// substring — the union over every matching table token. The token table
206    /// is always resident so the scan is in-memory; each matching token costs
207    /// one posting read (a range fetch on the remote path), so an unselective
208    /// piece matching more than [`SUBSTRING_TOKEN_CAP`] tokens returns `None`
209    /// and the caller falls back to its non-indexed path.
210    pub fn substring(&self, piece: &str) -> Option<Vec<u32>> {
211        let matching: Vec<&(String, u64, u64)> = self
212            .tokens
213            .iter()
214            .filter(|(t, _, _)| t.contains(piece))
215            .collect();
216        if matching.len() > SUBSTRING_TOKEN_CAP {
217            return None;
218        }
219        let mut out = BTreeSet::new();
220        for (_, off, len) in matching {
221            if let Some(b) = self.posting_for(*off, *len) {
222                out.extend(decode_postings(&b));
223            }
224        }
225        Some(out.into_iter().collect())
226    }
227
228    /// Subjects whose literals contain a word **starting with** `prefix` — the
229    /// union over every token in the `prefix…` range. Sorted, deduped.
230    pub fn prefix(&self, prefix: &str) -> Vec<u32> {
231        let start = self.tokens.partition_point(|(t, _, _)| t.as_str() < prefix);
232        let mut out = BTreeSet::new();
233        for (t, off, len) in &self.tokens[start..] {
234            if !t.starts_with(prefix) {
235                break;
236            }
237            if let Some(b) = self.posting_for(*off, *len) {
238                out.extend(decode_postings(&b));
239            }
240        }
241        out.into_iter().collect()
242    }
243}
244
245/// Parse the token table from a section (or a prefix covering it). Returns the
246/// `(token, off, len)` list and the byte offset where the postings blob begins.
247#[allow(clippy::type_complexity)]
248fn parse_token_table(
249    bytes: &[u8],
250    codec: u8,
251) -> Result<(Vec<(String, u64, u64)>, usize), FileError> {
252    let (ttlen, n) = read_uvarint(bytes).ok_or(FileError::Container("truncated text-index len"))?;
253    let end = n
254        .checked_add(ttlen as usize)
255        .filter(|&e| e <= bytes.len())
256        .ok_or(FileError::Container("text-index token table overruns"))?;
257    let tt = decompress(codec, &bytes[n..end])?;
258
259    let mut pos = 0usize;
260    let take = |pos: &mut usize| -> Option<u64> {
261        let (v, k) = read_uvarint(tt.get(*pos..)?)?;
262        *pos += k;
263        Some(v)
264    };
265    let count = take(&mut pos).ok_or(FileError::Container("truncated token count"))? as usize;
266    let mut tokens: Vec<(String, u64, u64)> = Vec::with_capacity(count.min(tt.len()));
267    let mut prev = String::new();
268    for _ in 0..count {
269        let shared = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
270        let slen = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
271        let sstart = pos;
272        let send = sstart
273            .checked_add(slen)
274            .filter(|&e| e <= tt.len())
275            .ok_or(FileError::Container("token suffix overruns"))?;
276        let suffix = &tt[sstart..send];
277        pos = send;
278        let off = take(&mut pos).ok_or(FileError::Container("truncated posting off"))?;
279        let len = take(&mut pos).ok_or(FileError::Container("truncated posting len"))?;
280        let base = prev.get(..shared.min(prev.len())).unwrap_or(&prev);
281        let mut token = String::with_capacity(base.len() + suffix.len());
282        token.push_str(base);
283        token.push_str(
284            std::str::from_utf8(suffix).map_err(|_| FileError::Container("bad token utf8"))?,
285        );
286        tokens.push((token.clone(), off, len));
287        prev = token;
288    }
289    Ok((tokens, end))
290}
291
292/// Decode a posting list: `count` then `count` delta-varint ascending subject ids.
293fn decode_postings(bytes: &[u8]) -> Vec<u32> {
294    let mut pos = 0usize;
295    let take = |pos: &mut usize| -> Option<u64> {
296        let (v, k) = read_uvarint(bytes.get(*pos..)?)?;
297        *pos += k;
298        Some(v)
299    };
300    let Some(count) = take(&mut pos) else {
301        return Vec::new();
302    };
303    let mut out = Vec::with_capacity((count as usize).min(bytes.len()));
304    let mut prev = 0u32;
305    for _ in 0..count {
306        let Some(d) = take(&mut pos) else { break };
307        prev = prev.wrapping_add(d as u32);
308        out.push(prev);
309    }
310    out
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::CODEC_NONE;
317
318    #[test]
319    fn tokenize_splits_lowercases_and_drops_short() {
320        // Splits on non-alphanumerics, lowercases, drops 1-char tokens ("D", "6", "β").
321        let got: Vec<String> = tokenize("Alpha-D-Glucose, 6-phosphate (β)").collect();
322        assert_eq!(
323            got.iter().map(String::as_str).collect::<Vec<_>>(),
324            ["alpha", "glucose", "phosphate"]
325        );
326    }
327
328    #[test]
329    fn round_trip_lookup_prefix_and_and() {
330        let mut b = TextIndexBuilder::new();
331        b.add_text("alpha-D-glucose", 10);
332        b.add_text("glucose 6-phosphate", 11);
333        b.add_text("benzene ring", 12);
334        let bytes = b.build(CODEC_NONE);
335        let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
336
337        assert_eq!(idx.lookup("glucose"), vec![10, 11]);
338        assert_eq!(idx.lookup("benzene"), vec![12]);
339        assert!(idx.lookup("missing").is_empty());
340        // prefix unions tokens (alpha, …) — here just "alpha".
341        assert_eq!(idx.prefix("alph"), vec![10]);
342        // token-prefix that spans several: "ph" → "phosphate".
343        assert_eq!(idx.prefix("phos"), vec![11]);
344    }
345
346    #[test]
347    fn remote_loader_fetches_only_the_queried_posting() {
348        use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
349        use std::sync::Arc;
350        let mut b = TextIndexBuilder::new();
351        b.add_text("glucose here", 1);
352        b.add_text("benzene there", 2);
353        let bytes = b.build(CODEC_NONE);
354        let base = TextIndex::postings_base(&bytes).unwrap();
355        let postings = bytes[base..].to_vec();
356
357        let calls = Arc::new(AtomicUsize::new(0));
358        let (p, c) = (postings.clone(), calls.clone());
359        let loader = Box::new(move |off: u64, len: u64| {
360            c.fetch_add(1, SeqCst);
361            p.get(off as usize..(off + len) as usize)
362                .map(<[u8]>::to_vec)
363        });
364        // Token table prefix = everything up to the postings blob.
365        let idx = TextIndex::from_token_table(&bytes[..base], CODEC_NONE, loader).unwrap();
366        assert_eq!(idx.lookup("glucose"), vec![1]);
367        assert_eq!(calls.load(SeqCst), 1, "one posting fetched");
368        assert!(idx.lookup("nope").is_empty());
369        assert_eq!(calls.load(SeqCst), 1, "an absent token fetches nothing");
370    }
371}