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.3). Every literal object in the index's **scope** is
5//! tokenized into words; the index maps each word to the **sorted subject ids**
6//! that carry it, so a reader can answer "which entities mention `glucose`?"
7//! without scanning the literals — and a *remote* reader fetches only the
8//! posting lists it queries.
9//!
10//! On-disk section layout:
11//! ```text
12//! varint  token_table_len
13//! token table (compressed with the file's block codec):
14//!   varint num_tokens
15//!   per token (sorted): varint shared_prefix_len   # front-coded vs previous token
16//!                       varint suffix_len, suffix bytes
17//!                       varint posting_off, varint posting_len   # into the postings blob
18//!   [scope block]       # optional, see below — absent for the default scope
19//! postings blob (uncompressed, so a single posting range-reads directly):
20//!   per token (same order): varint count, then `count` delta-varint subject ids
21//! ```
22//! The token table is small (distinct words) and read whole; the postings blob is
23//! the bulk and is fetched one posting at a time on the remote path.
24//!
25//! ## Scope
26//!
27//! By default the index covers the literal objects of the **default graph**,
28//! whatever their predicate — the only index any build wrote before 0.4.0. A
29//! build may widen it to every named graph (`--text-index-graphs all`) or
30//! narrow it to some predicates (`--text-index-include` / `--text-index-exclude`).
31//! A non-default scope is recorded in a **scope block** after the last token
32//! entry of the token table:
33//!
34//! ```text
35//! u8     1                       # scope-block version
36//! u8     graphs                  # 0 = default graph only, 1 = default + every named graph
37//! u8     predicates              # 0 = every predicate, 1 = only those listed, 2 = all but those listed
38//! varint n, then n × (varint len, bytes)   # predicate IRIs as N-Triples tokens `<…>`, ascending
39//! ```
40//!
41//! The default scope writes no block, so its section is byte-identical to one
42//! written before scopes existed, and a reader that predates the block stops
43//! after the token entries and never sees it. The scope matters to SPARQL: the
44//! `FILTER(CONTAINS(…))` pushdown treats the index as an over-approximation of
45//! the subjects whose literals can match, which holds only for the graphs and
46//! predicates the index covers. A **predicate-restricted** index is therefore
47//! stored under its own section kind ([`crate::header::SectionKind::TextIndexPartial`]):
48//! a reader older than that kind does not see the index at all and scans,
49//! instead of pruning with an index that never saw the predicate it filters on.
50
51use std::collections::{BTreeMap, BTreeSet};
52use std::io::{self, Write};
53
54use crate::file::{compress, decompress, FileError};
55use crate::varint::{read_uvarint, write_uvarint};
56
57/// Minimum token length kept (1-char words are low-value and bloat the index).
58const MIN_TOKEN_LEN: usize = 2;
59
60/// Most table tokens a [`TextIndex::substring`] piece may match before the
61/// lookup declines (each match is a posting read — a range fetch remotely).
62const SUBSTRING_TOKEN_CAP: usize = 512;
63
64/// Version byte of the scope block this crate writes.
65const SCOPE_BLOCK_VERSION: u8 = 1;
66
67/// Split text into index/query tokens: Unicode-alphanumeric runs, lowercased,
68/// length ≥ `MIN_TOKEN_LEN`. The build and query sides MUST use this same
69/// function so a query word matches how it was indexed.
70pub fn tokenize(text: &str) -> impl Iterator<Item = String> + '_ {
71    text.split(|c: char| !c.is_alphanumeric())
72        .filter(|t| t.chars().count() >= MIN_TOKEN_LEN)
73        .map(|t| t.to_lowercase())
74}
75
76/// Length of the longest shared **byte** prefix of `a` and `b`, clamped down to a
77/// char boundary of `b` so front-coded suffixes stay valid UTF-8. `b` is valid
78/// UTF-8, and a byte index is a char boundary of it exactly when it is the end
79/// or does not land on a continuation byte (`0b10xx_xxxx`) — the same rule as
80/// `str::is_char_boundary`, on bytes, so the spilled tokens of the
81/// memory-bounded build front-code exactly as the in-RAM `String`s do.
82fn shared_prefix(a: &[u8], b: &[u8]) -> usize {
83    let mut n = a.iter().zip(b).take_while(|(x, y)| x == y).count();
84    while n > 0 && n < b.len() && (b[n] & 0xC0) == 0x80 {
85        n -= 1;
86    }
87    n
88}
89
90// ---------------------------------------------------------------------------
91// Scope
92// ---------------------------------------------------------------------------
93
94/// Which graphs' literals a text index covers (`rete build --text-index-graphs`).
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
96pub enum TextIndexGraphs {
97    /// The default graph only — what every index built before 0.4.0 covers.
98    #[default]
99    Default,
100    /// The default graph and every named graph.
101    All,
102}
103
104/// Which predicates' literals a text index covers.
105#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
106pub enum TextIndexPredicates {
107    /// Every predicate.
108    #[default]
109    All,
110    /// Only these predicates (N-Triples `<iri>` tokens, ascending, distinct).
111    Only(Vec<String>),
112    /// Every predicate except these (same spelling).
113    Except(Vec<String>),
114}
115
116/// What a text index covers: a graph scope and a predicate filter. The
117/// [`Default`] is the pre-0.4.0 index — the default graph, every predicate —
118/// and it is the one scope that writes no scope block.
119#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
120pub struct TextIndexScope {
121    /// The graphs whose literals are indexed.
122    pub graphs: TextIndexGraphs,
123    /// The predicates whose literals are indexed.
124    pub predicates: TextIndexPredicates,
125}
126
127/// Spell a predicate IRI the way the dictionary stores it: an N-Triples token
128/// `<…>`. Accepts the IRI bare or already bracketed; refuses anything that is
129/// not an absolute IRI (no scheme, whitespace, brackets inside), because a
130/// typo here silently indexes the wrong literals.
131fn predicate_token(iri: &str) -> Result<String, String> {
132    let t = iri.trim();
133    let inner = t
134        .strip_prefix('<')
135        .and_then(|r| r.strip_suffix('>'))
136        .unwrap_or(t);
137    let scheme_ok = inner
138        .split_once(':')
139        .is_some_and(|(s, rest)| !s.is_empty() && !rest.is_empty());
140    if inner.is_empty()
141        || !scheme_ok
142        || inner
143            .chars()
144            .any(|c| c.is_whitespace() || matches!(c, '<' | '>' | '"'))
145    {
146        return Err(format!(
147            "`{iri}` is not an absolute predicate IRI (expected e.g. http://purl.org/dc/terms/description)"
148        ));
149    }
150    Ok(format!("<{inner}>"))
151}
152
153impl TextIndexScope {
154    /// Build a scope from the CLI's spelling: a graph scope and predicate IRIs
155    /// to include or to exclude (bare or `<…>`). Giving both lists is an error —
156    /// an include list already says exactly what is indexed. Empty lists mean
157    /// every predicate.
158    pub fn from_options<I, E>(
159        graphs: TextIndexGraphs,
160        include: I,
161        exclude: E,
162    ) -> Result<Self, String>
163    where
164        I: IntoIterator,
165        I::Item: AsRef<str>,
166        E: IntoIterator,
167        E::Item: AsRef<str>,
168    {
169        let norm = |items: Vec<String>| -> Result<Vec<String>, String> {
170            let mut out = items
171                .iter()
172                .map(|s| predicate_token(s))
173                .collect::<Result<Vec<_>, _>>()?;
174            out.sort();
175            out.dedup();
176            Ok(out)
177        };
178        let include = norm(
179            include
180                .into_iter()
181                .map(|s| s.as_ref().to_string())
182                .collect(),
183        )?;
184        let exclude = norm(
185            exclude
186                .into_iter()
187                .map(|s| s.as_ref().to_string())
188                .collect(),
189        )?;
190        let predicates = match (include.is_empty(), exclude.is_empty()) {
191            (true, true) => TextIndexPredicates::All,
192            (false, true) => TextIndexPredicates::Only(include),
193            (true, false) => TextIndexPredicates::Except(exclude),
194            (false, false) => {
195                return Err(
196                    "give --text-index-include or --text-index-exclude, not both".to_string(),
197                )
198            }
199        };
200        Ok(TextIndexScope { graphs, predicates })
201    }
202
203    /// The pre-0.4.0 scope (default graph, every predicate) — written with no
204    /// scope block.
205    pub fn is_default(&self) -> bool {
206        *self == TextIndexScope::default()
207    }
208
209    /// Does the scope leave some predicate's literals out? Such an index is
210    /// written under [`crate::header::SectionKind::TextIndexPartial`] so that
211    /// readers which do not know scopes never prune a filter with it.
212    pub fn restricts_predicates(&self) -> bool {
213        !matches!(self.predicates, TextIndexPredicates::All)
214    }
215
216    /// Are the named graphs' literals indexed too?
217    pub fn covers_named_graphs(&self) -> bool {
218        self.graphs == TextIndexGraphs::All
219    }
220
221    /// Are literals of predicate `p` (an N-Triples token `<…>`) indexed?
222    pub fn covers_predicate(&self, p: &str) -> bool {
223        match &self.predicates {
224            TextIndexPredicates::All => true,
225            TextIndexPredicates::Only(list) => list.binary_search_by(|x| x.as_str().cmp(p)).is_ok(),
226            TextIndexPredicates::Except(list) => {
227                list.binary_search_by(|x| x.as_str().cmp(p)).is_err()
228            }
229        }
230    }
231
232    /// Does the index cover a triple pattern's literals? `p` is the pattern's
233    /// constant predicate, or `None` for a variable one — which can bind any
234    /// predicate, so only an every-predicate index covers it.
235    pub fn covers_pattern_predicate(&self, p: Option<&str>) -> bool {
236        match p {
237            Some(p) => self.covers_predicate(p),
238            None => !self.restricts_predicates(),
239        }
240    }
241
242    /// The predicate list of an include/exclude scope (empty for every-predicate).
243    pub fn listed_predicates(&self) -> &[String] {
244        match &self.predicates {
245            TextIndexPredicates::All => &[],
246            TextIndexPredicates::Only(l) | TextIndexPredicates::Except(l) => l,
247        }
248    }
249
250    /// One line for logs and `rete info`: what the index covers.
251    pub fn describe(&self) -> String {
252        let graphs = match self.graphs {
253            TextIndexGraphs::Default => "default graph",
254            TextIndexGraphs::All => "default + named graphs",
255        };
256        match &self.predicates {
257            TextIndexPredicates::All => format!("{graphs}, every predicate"),
258            TextIndexPredicates::Only(l) => format!("{graphs}, only {}", l.join(" ")),
259            TextIndexPredicates::Except(l) => format!("{graphs}, all but {}", l.join(" ")),
260        }
261    }
262
263    /// Append the scope block to an uncompressed token table — nothing at all
264    /// for the default scope.
265    pub(crate) fn encode(&self, out: &mut Vec<u8>) {
266        if self.is_default() {
267            return;
268        }
269        out.push(SCOPE_BLOCK_VERSION);
270        out.push(match self.graphs {
271            TextIndexGraphs::Default => 0,
272            TextIndexGraphs::All => 1,
273        });
274        out.push(match self.predicates {
275            TextIndexPredicates::All => 0,
276            TextIndexPredicates::Only(_) => 1,
277            TextIndexPredicates::Except(_) => 2,
278        });
279        let list = self.listed_predicates();
280        write_uvarint(out, list.len() as u64);
281        for p in list {
282            write_uvarint(out, p.len() as u64);
283            out.extend_from_slice(p.as_bytes());
284        }
285    }
286
287    /// Decode the bytes after the last token entry. Empty = the default scope.
288    /// A block of a version this reader does not know decodes as a scope that
289    /// covers **no** predicate: the index still answers `rete search`, and the
290    /// SPARQL pushdown never prunes with coverage it cannot read.
291    fn decode(bytes: &[u8]) -> Result<Self, FileError> {
292        let Some((&version, rest)) = bytes.split_first() else {
293            return Ok(TextIndexScope::default());
294        };
295        if version != SCOPE_BLOCK_VERSION {
296            return Ok(TextIndexScope {
297                graphs: TextIndexGraphs::Default,
298                predicates: TextIndexPredicates::Only(Vec::new()),
299            });
300        }
301        let bad = FileError::Container("malformed text-index scope block");
302        let (&g, rest) = rest.split_first().ok_or(bad)?;
303        let (&m, rest) = rest
304            .split_first()
305            .ok_or(FileError::Container("malformed text-index scope block"))?;
306        let graphs = match g {
307            0 => TextIndexGraphs::Default,
308            1 => TextIndexGraphs::All,
309            _ => return Err(FileError::Container("unknown text-index graph scope")),
310        };
311        let mut pos = 0usize;
312        let (n, k) =
313            read_uvarint(rest).ok_or(FileError::Container("malformed text-index scope block"))?;
314        pos += k;
315        let mut list = Vec::with_capacity((n as usize).min(rest.len()));
316        for _ in 0..n {
317            let (len, k) = read_uvarint(rest.get(pos..).unwrap_or_default())
318                .ok_or(FileError::Container("malformed text-index scope block"))?;
319            pos += k;
320            let end = pos
321                .checked_add(len as usize)
322                .filter(|&e| e <= rest.len())
323                .ok_or(FileError::Container("text-index scope predicate overruns"))?;
324            let p = std::str::from_utf8(&rest[pos..end])
325                .map_err(|_| FileError::Container("bad text-index scope utf8"))?;
326            list.push(p.to_string());
327            pos = end;
328        }
329        let predicates = match m {
330            0 => TextIndexPredicates::All,
331            1 => TextIndexPredicates::Only(list),
332            2 => TextIndexPredicates::Except(list),
333            _ => return Err(FileError::Container("unknown text-index predicate scope")),
334        };
335        Ok(TextIndexScope { graphs, predicates })
336    }
337}
338
339/// A predicate-restricted section as written (kind 11): the 16-byte
340/// self-digest `blake3(payload)[..16]`, then the payload — the kind-6 layout.
341/// The digest keeps the section verifiable although it is outside the content
342/// hash (an older `verify`, which cannot see kind 11, must not fail on it).
343pub(crate) fn with_partial_digest(payload: &[u8]) -> Vec<u8> {
344    let mut out = Vec::with_capacity(payload.len() + 16);
345    out.extend_from_slice(&blake3::hash(payload).as_bytes()[..16]);
346    out.extend_from_slice(payload);
347    out
348}
349
350/// What a build's `with_text_index` argument may be: `false` / `true` (no
351/// index / the default scope — the spelling every caller used before scopes),
352/// a [`TextIndexScope`], or an `Option` of one.
353pub trait IntoTextIndex {
354    /// `None` = build no text index.
355    fn into_text_index(self) -> Option<TextIndexScope>;
356}
357
358impl IntoTextIndex for bool {
359    fn into_text_index(self) -> Option<TextIndexScope> {
360        self.then(TextIndexScope::default)
361    }
362}
363
364impl IntoTextIndex for TextIndexScope {
365    fn into_text_index(self) -> Option<TextIndexScope> {
366        Some(self)
367    }
368}
369
370impl IntoTextIndex for Option<TextIndexScope> {
371    fn into_text_index(self) -> Option<TextIndexScope> {
372        self
373    }
374}
375
376impl IntoTextIndex for &Option<TextIndexScope> {
377    fn into_text_index(self) -> Option<TextIndexScope> {
378        self.clone()
379    }
380}
381
382// ---------------------------------------------------------------------------
383// Writing
384// ---------------------------------------------------------------------------
385
386/// Append subject `s` to a posting's delta-varint run (`prev` starts at 0;
387/// subjects must arrive strictly ascending).
388pub(crate) fn push_delta(out: &mut Vec<u8>, prev: &mut u32, s: u32) {
389    write_uvarint(out, (s - *prev) as u64);
390    *prev = s;
391}
392
393/// The one serializer of the token table entries and the postings blob, shared
394/// by the in-RAM [`TextIndexBuilder`] and the memory-bounded build
395/// (`text_index_spill`): both feed it the same `(token, subjects)` sequence in
396/// ascending token order, so the two produce the same bytes by construction.
397/// It writes the token entries **without** the leading `num_tokens` varint (not
398/// known until the end) — the caller prepends it, then appends the scope block.
399pub(crate) struct SectionWriter<E: Write, P: Write> {
400    entries: E,
401    postings: P,
402    prev: Vec<u8>,
403    tokens: u64,
404    offset: u64,
405}
406
407impl<E: Write, P: Write> SectionWriter<E, P> {
408    pub(crate) fn new(entries: E, postings: P) -> Self {
409        SectionWriter {
410            entries,
411            postings,
412            prev: Vec::new(),
413            tokens: 0,
414            offset: 0,
415        }
416    }
417
418    /// Append one token: its `count` subjects, already delta-encoded.
419    pub(crate) fn push(&mut self, token: &[u8], count: u64, deltas: &[u8]) -> io::Result<()> {
420        self.push_with(token, count, deltas.len() as u64, |p| p.write_all(deltas))
421    }
422
423    /// Append one token whose `deltas_len` bytes of deltas `write` emits
424    /// (the spilled path copies an oversized posting from disk).
425    pub(crate) fn push_with(
426        &mut self,
427        token: &[u8],
428        count: u64,
429        deltas_len: u64,
430        write: impl FnOnce(&mut P) -> io::Result<()>,
431    ) -> io::Result<()> {
432        let mut head = Vec::with_capacity(10);
433        write_uvarint(&mut head, count);
434        self.postings.write_all(&head)?;
435        write(&mut self.postings)?;
436        let len = head.len() as u64 + deltas_len;
437
438        let shared = shared_prefix(&self.prev, token);
439        let suffix = &token[shared..];
440        let mut e = Vec::with_capacity(suffix.len() + 16);
441        write_uvarint(&mut e, shared as u64);
442        write_uvarint(&mut e, suffix.len() as u64);
443        e.extend_from_slice(suffix);
444        write_uvarint(&mut e, self.offset);
445        write_uvarint(&mut e, len);
446        self.entries.write_all(&e)?;
447
448        self.offset += len;
449        self.tokens += 1;
450        self.prev.clear();
451        self.prev.extend_from_slice(token);
452        Ok(())
453    }
454
455    /// The two sinks, the token count and the postings-blob length.
456    pub(crate) fn finish(self) -> (E, P, u64, u64) {
457        (self.entries, self.postings, self.tokens, self.offset)
458    }
459}
460
461/// Accumulates `token → subjects` and serializes the section.
462#[derive(Default)]
463pub struct TextIndexBuilder {
464    postings: BTreeMap<String, BTreeSet<u32>>,
465    scope: TextIndexScope,
466}
467
468impl TextIndexBuilder {
469    /// A builder for the default scope (default graph, every predicate).
470    pub fn new() -> Self {
471        Self::default()
472    }
473
474    /// A builder whose section records `scope` (the caller decides which
475    /// literals to feed it; this only writes the scope block).
476    pub fn with_scope(scope: TextIndexScope) -> Self {
477        TextIndexBuilder {
478            postings: BTreeMap::new(),
479            scope,
480        }
481    }
482
483    /// Record that `subject` carries `token`.
484    pub fn add(&mut self, token: &str, subject: u32) {
485        self.postings
486            .entry(token.to_string())
487            .or_default()
488            .insert(subject);
489    }
490
491    /// Tokenize `text` and record every word for `subject`.
492    pub fn add_text(&mut self, text: &str, subject: u32) {
493        for tok in tokenize(text) {
494            self.postings.entry(tok).or_default().insert(subject);
495        }
496    }
497
498    /// No token recorded (the build then writes no section at all).
499    pub fn is_empty(&self) -> bool {
500        self.postings.is_empty()
501    }
502
503    /// Serialize the section (deterministic: tokens sorted, subjects ascending).
504    pub fn build(&self, codec: u8) -> Vec<u8> {
505        let mut w = SectionWriter::new(Vec::new(), Vec::new());
506        let mut deltas = Vec::new();
507        for (token, subjects) in &self.postings {
508            deltas.clear();
509            let mut prev = 0u32;
510            for &s in subjects {
511                push_delta(&mut deltas, &mut prev, s);
512            }
513            w.push(token.as_bytes(), subjects.len() as u64, &deltas)
514                .expect("writes to a Vec are infallible");
515        }
516        let (entries, postings, tokens, _) = w.finish();
517        let mut tt = Vec::with_capacity(entries.len() + 16);
518        write_uvarint(&mut tt, tokens);
519        tt.extend_from_slice(&entries);
520        self.scope.encode(&mut tt);
521        let ctt = compress(codec, &tt);
522
523        let mut out = Vec::with_capacity(10 + ctt.len() + postings.len());
524        write_uvarint(&mut out, ctt.len() as u64);
525        out.extend_from_slice(&ctt);
526        out.extend_from_slice(&postings);
527        out
528    }
529}
530
531/// Build the TEXT_INDEX section of an in-RAM build from the dictionary and the
532/// encoded id-triples: every literal object of the graphs and predicates in
533/// `scope` is tokenized and its words recorded for the triple's subject.
534/// `named` is consulted only when the scope covers named graphs. Empty when no
535/// literal is in scope — the build then writes no section.
536///
537/// Prints one line per listed predicate that no literal matched: a misspelt
538/// `--text-index-exclude` would otherwise exclude nothing, silently.
539pub(crate) fn build_section<'a>(
540    dict: &crate::dictionary::Dictionary,
541    scope: &TextIndexScope,
542    default: &[(u32, u32, u32)],
543    named: impl Iterator<Item = &'a [(u32, u32, u32)]>,
544    codec: u8,
545) -> Vec<u8> {
546    use crate::terms::{is_literal, literal_lexical};
547    // Resolve the listed predicates to ids once; an IRI the graph never uses as
548    // a predicate has no id and can match nothing.
549    let listed: Vec<Option<u32>> = scope
550        .listed_predicates()
551        .iter()
552        .map(|p| dict.predicate_id(p))
553        .collect();
554    let mut seen = vec![false; listed.len()];
555    let mut covered = |p: u32| -> bool {
556        let hit = listed.iter().position(|id| *id == Some(p));
557        if let Some(i) = hit {
558            seen[i] = true;
559        }
560        match scope.predicates {
561            TextIndexPredicates::All => true,
562            TextIndexPredicates::Only(_) => hit.is_some(),
563            TextIndexPredicates::Except(_) => hit.is_none(),
564        }
565    };
566    let mut b = TextIndexBuilder::with_scope(scope.clone());
567    let named: Vec<&[(u32, u32, u32)]> = if scope.covers_named_graphs() {
568        named.collect()
569    } else {
570        Vec::new()
571    };
572    for triples in std::iter::once(default).chain(named) {
573        for &(s, p, o) in triples {
574            let Some(term) = dict.object_term(o) else {
575                continue;
576            };
577            if !is_literal(&term) || !covered(p) {
578                continue;
579            }
580            if let Some(lit) = literal_lexical(&term) {
581                b.add_text(&lit, s);
582            }
583        }
584    }
585    warn_unmatched(scope, &seen);
586    if b.is_empty() {
587        Vec::new()
588    } else {
589        b.build(codec)
590    }
591}
592
593/// Report each `--text-index-include`/`--text-index-exclude` predicate that no
594/// in-scope literal carried (`seen[i]` for `scope.listed_predicates()[i]`).
595pub(crate) fn warn_unmatched(scope: &TextIndexScope, seen: &[bool]) {
596    let flag = match scope.predicates {
597        TextIndexPredicates::All => return,
598        TextIndexPredicates::Only(_) => "--text-index-include",
599        TextIndexPredicates::Except(_) => "--text-index-exclude",
600    };
601    for (p, _) in scope
602        .listed_predicates()
603        .iter()
604        .zip(seen)
605        .filter(|(_, s)| !**s)
606    {
607        eprintln!(
608            "warning: {flag} {p}: no literal object with this predicate in the indexed graphs"
609        );
610    }
611}
612
613// ---------------------------------------------------------------------------
614// Reading
615// ---------------------------------------------------------------------------
616
617/// Where a [`TextIndex`] reads posting lists from.
618enum Postings {
619    /// The whole postings blob, resident (local / fully-loaded reads).
620    Resident(Vec<u8>),
621    /// Fetch one posting `(offset_within_blob, len)` on demand (remote reads).
622    Remote(Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>),
623}
624
625/// A parsed text index: the token table (always resident) plus a posting source.
626pub struct TextIndex {
627    /// `(token, posting_offset, posting_len)`, sorted by token.
628    tokens: Vec<(String, u64, u64)>,
629    postings: Postings,
630    scope: TextIndexScope,
631}
632
633impl TextIndex {
634    /// Parse a whole section (local): token table + the full postings blob resident.
635    pub fn from_section(section: &[u8], codec: u8) -> Result<Self, FileError> {
636        let (tokens, scope, postings_start) = parse_token_table(section, codec)?;
637        let postings = section
638            .get(postings_start..)
639            .ok_or(FileError::Container("text-index postings overrun"))?
640            .to_vec();
641        Ok(TextIndex {
642            tokens,
643            postings: Postings::Resident(postings),
644            scope,
645        })
646    }
647
648    /// Build from a section **prefix** holding the token table, plus a loader that
649    /// fetches a posting `(offset_within_postings_blob, len)` on demand (remote).
650    pub fn from_token_table(
651        prefix: &[u8],
652        codec: u8,
653        loader: Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>,
654    ) -> Result<Self, FileError> {
655        let (tokens, scope, _postings_start) = parse_token_table(prefix, codec)?;
656        Ok(TextIndex {
657            tokens,
658            postings: Postings::Remote(loader),
659            scope,
660        })
661    }
662
663    /// Byte offset of the postings blob within the section (token-table end).
664    /// The remote opener uses this to base its posting-range loader.
665    pub fn postings_base(section_prefix: &[u8]) -> Option<usize> {
666        let (len, n) = read_uvarint(section_prefix)?;
667        Some(n + len as usize)
668    }
669
670    /// Number of distinct tokens.
671    pub fn token_count(&self) -> usize {
672        self.tokens.len()
673    }
674
675    /// What this index covers (the default scope for every pre-0.4.0 file).
676    pub fn scope(&self) -> &TextIndexScope {
677        &self.scope
678    }
679
680    fn posting_for(&self, off: u64, len: u64) -> Option<Vec<u8>> {
681        match &self.postings {
682            Postings::Resident(blob) => blob
683                .get(off as usize..(off + len) as usize)
684                .map(<[u8]>::to_vec),
685            Postings::Remote(load) => load(off, len),
686        }
687    }
688
689    /// Subjects whose literals contain the exact `token` (case-insensitive — the
690    /// caller passes a [`tokenize`]d word), sorted ascending.
691    pub fn lookup(&self, token: &str) -> Vec<u32> {
692        let i = self.tokens.partition_point(|(t, _, _)| t.as_str() < token);
693        match self.tokens.get(i) {
694            Some((t, off, len)) if t == token => self
695                .posting_for(*off, *len)
696                .map(|b| decode_postings(&b))
697                .unwrap_or_default(),
698            _ => Vec::new(),
699        }
700    }
701
702    /// Subjects whose literals contain a word that CONTAINS `piece` as a
703    /// substring — the union over every matching table token. The token table
704    /// is always resident so the scan is in-memory; each matching token costs
705    /// one posting read (a range fetch on the remote path), so an unselective
706    /// piece matching more than [`SUBSTRING_TOKEN_CAP`] tokens returns `None`
707    /// and the caller falls back to its non-indexed path.
708    pub fn substring(&self, piece: &str) -> Option<Vec<u32>> {
709        let matching: Vec<&(String, u64, u64)> = self
710            .tokens
711            .iter()
712            .filter(|(t, _, _)| t.contains(piece))
713            .collect();
714        if matching.len() > SUBSTRING_TOKEN_CAP {
715            return None;
716        }
717        let mut out = BTreeSet::new();
718        for (_, off, len) in matching {
719            if let Some(b) = self.posting_for(*off, *len) {
720                out.extend(decode_postings(&b));
721            }
722        }
723        Some(out.into_iter().collect())
724    }
725
726    /// Subjects whose literals contain a word **starting with** `prefix` — the
727    /// union over every token in the `prefix…` range. Sorted, deduped.
728    pub fn prefix(&self, prefix: &str) -> Vec<u32> {
729        let start = self.tokens.partition_point(|(t, _, _)| t.as_str() < prefix);
730        let mut out = BTreeSet::new();
731        for (t, off, len) in &self.tokens[start..] {
732            if !t.starts_with(prefix) {
733                break;
734            }
735            if let Some(b) = self.posting_for(*off, *len) {
736                out.extend(decode_postings(&b));
737            }
738        }
739        out.into_iter().collect()
740    }
741}
742
743/// Parse the token table from a section (or a prefix covering it). Returns the
744/// `(token, off, len)` list, the scope, and the byte offset where the postings
745/// blob begins.
746#[allow(clippy::type_complexity)]
747fn parse_token_table(
748    bytes: &[u8],
749    codec: u8,
750) -> Result<(Vec<(String, u64, u64)>, TextIndexScope, usize), FileError> {
751    let (ttlen, n) = read_uvarint(bytes).ok_or(FileError::Container("truncated text-index len"))?;
752    let end = n
753        .checked_add(ttlen as usize)
754        .filter(|&e| e <= bytes.len())
755        .ok_or(FileError::Container("text-index token table overruns"))?;
756    let tt = decompress(codec, &bytes[n..end])?;
757
758    let mut pos = 0usize;
759    let take = |pos: &mut usize| -> Option<u64> {
760        let (v, k) = read_uvarint(tt.get(*pos..)?)?;
761        *pos += k;
762        Some(v)
763    };
764    let count = take(&mut pos).ok_or(FileError::Container("truncated token count"))? as usize;
765    let mut tokens: Vec<(String, u64, u64)> = Vec::with_capacity(count.min(tt.len()));
766    let mut prev = String::new();
767    for _ in 0..count {
768        let shared = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
769        let slen = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
770        let sstart = pos;
771        let send = sstart
772            .checked_add(slen)
773            .filter(|&e| e <= tt.len())
774            .ok_or(FileError::Container("token suffix overruns"))?;
775        let suffix = &tt[sstart..send];
776        pos = send;
777        let off = take(&mut pos).ok_or(FileError::Container("truncated posting off"))?;
778        let len = take(&mut pos).ok_or(FileError::Container("truncated posting len"))?;
779        let base = prev.get(..shared.min(prev.len())).unwrap_or(&prev);
780        let mut token = String::with_capacity(base.len() + suffix.len());
781        token.push_str(base);
782        token.push_str(
783            std::str::from_utf8(suffix).map_err(|_| FileError::Container("bad token utf8"))?,
784        );
785        tokens.push((token.clone(), off, len));
786        prev = token;
787    }
788    let scope = TextIndexScope::decode(tt.get(pos..).unwrap_or_default())?;
789    Ok((tokens, scope, end))
790}
791
792/// Decode a posting list: `count` then `count` delta-varint ascending subject ids.
793fn decode_postings(bytes: &[u8]) -> Vec<u32> {
794    let mut pos = 0usize;
795    let take = |pos: &mut usize| -> Option<u64> {
796        let (v, k) = read_uvarint(bytes.get(*pos..)?)?;
797        *pos += k;
798        Some(v)
799    };
800    let Some(count) = take(&mut pos) else {
801        return Vec::new();
802    };
803    let mut out = Vec::with_capacity((count as usize).min(bytes.len()));
804    let mut prev = 0u32;
805    for _ in 0..count {
806        let Some(d) = take(&mut pos) else { break };
807        prev = prev.wrapping_add(d as u32);
808        out.push(prev);
809    }
810    out
811}
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816    use crate::CODEC_NONE;
817
818    #[test]
819    fn tokenize_splits_lowercases_and_drops_short() {
820        // Splits on non-alphanumerics, lowercases, drops 1-char tokens ("D", "6", "β").
821        let got: Vec<String> = tokenize("Alpha-D-Glucose, 6-phosphate (β)").collect();
822        assert_eq!(
823            got.iter().map(String::as_str).collect::<Vec<_>>(),
824            ["alpha", "glucose", "phosphate"]
825        );
826    }
827
828    #[test]
829    fn round_trip_lookup_prefix_and_and() {
830        let mut b = TextIndexBuilder::new();
831        b.add_text("alpha-D-glucose", 10);
832        b.add_text("glucose 6-phosphate", 11);
833        b.add_text("benzene ring", 12);
834        let bytes = b.build(CODEC_NONE);
835        let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
836
837        assert_eq!(idx.lookup("glucose"), vec![10, 11]);
838        assert_eq!(idx.lookup("benzene"), vec![12]);
839        assert!(idx.lookup("missing").is_empty());
840        // prefix unions tokens (alpha, …) — here just "alpha".
841        assert_eq!(idx.prefix("alph"), vec![10]);
842        // token-prefix that spans several: "ph" → "phosphate".
843        assert_eq!(idx.prefix("phos"), vec![11]);
844        assert!(idx.scope().is_default());
845    }
846
847    #[test]
848    fn remote_loader_fetches_only_the_queried_posting() {
849        use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
850        use std::sync::Arc;
851        let mut b = TextIndexBuilder::new();
852        b.add_text("glucose here", 1);
853        b.add_text("benzene there", 2);
854        let bytes = b.build(CODEC_NONE);
855        let base = TextIndex::postings_base(&bytes).unwrap();
856        let postings = bytes[base..].to_vec();
857
858        let calls = Arc::new(AtomicUsize::new(0));
859        let (p, c) = (postings.clone(), calls.clone());
860        let loader = Box::new(move |off: u64, len: u64| {
861            c.fetch_add(1, SeqCst);
862            p.get(off as usize..(off + len) as usize)
863                .map(<[u8]>::to_vec)
864        });
865        // Token table prefix = everything up to the postings blob.
866        let idx = TextIndex::from_token_table(&bytes[..base], CODEC_NONE, loader).unwrap();
867        assert_eq!(idx.lookup("glucose"), vec![1]);
868        assert_eq!(calls.load(SeqCst), 1, "one posting fetched");
869        assert!(idx.lookup("nope").is_empty());
870        assert_eq!(calls.load(SeqCst), 1, "an absent token fetches nothing");
871    }
872
873    /// The byte-level prefix rule must agree with `str::is_char_boundary`, or
874    /// the spilled build (bytes) and the in-RAM build (`String`s) would
875    /// front-code multi-byte tokens differently.
876    #[test]
877    fn shared_prefix_on_bytes_matches_the_str_rule() {
878        let words = [
879            "",
880            "a",
881            "ab",
882            "abc",
883            "é",
884            "éa",
885            "éé",
886            "eé",
887            "ü",
888            "ünïcode",
889            "ünïcodé",
890            "日本",
891            "日本語",
892            "日",
893            "z",
894            "zß",
895            "zss",
896        ];
897        for a in words {
898            for b in words {
899                let mut n = a
900                    .as_bytes()
901                    .iter()
902                    .zip(b.as_bytes())
903                    .take_while(|(x, y)| x == y)
904                    .count();
905                while n > 0 && !b.is_char_boundary(n) {
906                    n -= 1;
907                }
908                assert_eq!(
909                    shared_prefix(a.as_bytes(), b.as_bytes()),
910                    n,
911                    "{a:?} vs {b:?}"
912                );
913            }
914        }
915    }
916
917    /// The pinned bytes of a default-scope section: what every build wrote
918    /// before scopes existed, so a refactor of the writer cannot move them.
919    #[test]
920    fn default_scope_section_bytes_are_pinned() {
921        let mut b = TextIndexBuilder::new();
922        b.add_text("ab ac", 1);
923        b.add_text("ab", 3);
924        let bytes = b.build(CODEC_NONE);
925        // tt = [2 tokens] [0,2,"ab",off 0,len 3] [1,1,"c",off 3,len 2]
926        // postings = [2, 1, 2] [1, 1]
927        let tt: &[u8] = &[2, 0, 2, b'a', b'b', 0, 3, 1, 1, b'c', 3, 2];
928        let mut want = vec![tt.len() as u8];
929        want.extend_from_slice(tt);
930        want.extend_from_slice(&[2, 1, 2, 1, 1]);
931        assert_eq!(bytes, want);
932    }
933
934    #[test]
935    fn scope_block_round_trips_and_default_writes_none() {
936        let mut tt = Vec::new();
937        TextIndexScope::default().encode(&mut tt);
938        assert!(tt.is_empty(), "the default scope writes no block");
939
940        let scopes = [
941            TextIndexScope {
942                graphs: TextIndexGraphs::All,
943                predicates: TextIndexPredicates::All,
944            },
945            TextIndexScope::from_options(
946                TextIndexGraphs::Default,
947                Vec::<String>::new(),
948                ["http://purl.org/dc/terms/description", "<http://ex/b>"],
949            )
950            .unwrap(),
951            TextIndexScope::from_options(
952                TextIndexGraphs::All,
953                ["http://ex/z", "http://ex/a", "http://ex/z"],
954                Vec::<String>::new(),
955            )
956            .unwrap(),
957        ];
958        for scope in scopes {
959            let mut b = TextIndexBuilder::with_scope(scope.clone());
960            b.add_text("word", 1);
961            let bytes = b.build(CODEC_NONE);
962            let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
963            assert_eq!(idx.scope(), &scope);
964            assert_eq!(idx.lookup("word"), vec![1]);
965            // The remote path parses the same table prefix to the same scope.
966            let base = TextIndex::postings_base(&bytes).unwrap();
967            let remote =
968                TextIndex::from_token_table(&bytes[..base], CODEC_NONE, Box::new(|_, _| None))
969                    .unwrap();
970            assert_eq!(remote.scope(), &scope);
971        }
972    }
973
974    #[test]
975    fn scope_options_normalize_and_validate() {
976        let s = TextIndexScope::from_options(
977            TextIndexGraphs::Default,
978            Vec::<String>::new(),
979            ["<http://ex/b>", "http://ex/a", "http://ex/b"],
980        )
981        .unwrap();
982        assert_eq!(
983            s.predicates,
984            TextIndexPredicates::Except(vec!["<http://ex/a>".into(), "<http://ex/b>".into()])
985        );
986        assert!(s.restricts_predicates());
987        assert!(!s.covers_predicate("<http://ex/a>"));
988        assert!(s.covers_predicate("<http://ex/c>"));
989        assert!(s.covers_pattern_predicate(Some("<http://ex/c>")));
990        assert!(
991            !s.covers_pattern_predicate(None),
992            "a variable predicate can bind ex:a"
993        );
994
995        let only = TextIndexScope::from_options(
996            TextIndexGraphs::All,
997            ["http://ex/a"],
998            Vec::<String>::new(),
999        )
1000        .unwrap();
1001        assert!(only.covers_predicate("<http://ex/a>"));
1002        assert!(!only.covers_predicate("<http://ex/b>"));
1003        assert!(only.covers_named_graphs());
1004
1005        let all = TextIndexScope::default();
1006        assert!(all.covers_pattern_predicate(None));
1007        assert!(!all.restricts_predicates());
1008
1009        for bad in ["description", "", "http://ex/a b", "<http://ex/a", ":x"] {
1010            assert!(
1011                TextIndexScope::from_options(TextIndexGraphs::Default, [bad], Vec::<String>::new())
1012                    .is_err(),
1013                "{bad:?} should be refused"
1014            );
1015        }
1016        assert!(TextIndexScope::from_options(
1017            TextIndexGraphs::Default,
1018            ["http://ex/a"],
1019            ["http://ex/b"]
1020        )
1021        .is_err());
1022    }
1023
1024    /// A scope block of a later version must not make the file unreadable, and
1025    /// must not be read as covering anything the SPARQL pushdown could prune on.
1026    #[test]
1027    fn unknown_scope_version_covers_no_predicate() {
1028        let s = TextIndexScope::decode(&[9, 1, 2, 3]).unwrap();
1029        assert!(!s.covers_predicate("<http://ex/a>"));
1030        assert!(!s.covers_pattern_predicate(None));
1031        // A truncated v1 block is malformed, not silently the default.
1032        assert!(TextIndexScope::decode(&[1, 0]).is_err());
1033        assert!(TextIndexScope::decode(&[1, 0, 1, 1, 5, b'<']).is_err());
1034    }
1035}