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::{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        // The streaming encoder, not the one-shot `compress`: the
522        // memory-bounded build compresses this table from disk, and past the
523        // zstd window the two encoders' bytes differ (see `compress_to`).
524        let ctt = crate::file::compress_streamed(codec, &tt);
525
526        let mut out = Vec::with_capacity(10 + ctt.len() + postings.len());
527        write_uvarint(&mut out, ctt.len() as u64);
528        out.extend_from_slice(&ctt);
529        out.extend_from_slice(&postings);
530        out
531    }
532}
533
534/// Build the TEXT_INDEX section of an in-RAM build from the dictionary and the
535/// encoded id-triples: every literal object of the graphs and predicates in
536/// `scope` is tokenized and its words recorded for the triple's subject.
537/// `named` is consulted only when the scope covers named graphs. Empty when no
538/// literal is in scope — the build then writes no section.
539///
540/// Prints one line per listed predicate that no literal matched: a misspelt
541/// `--text-index-exclude` would otherwise exclude nothing, silently.
542pub(crate) fn build_section<'a>(
543    dict: &crate::dictionary::Dictionary,
544    scope: &TextIndexScope,
545    default: &[(u32, u32, u32)],
546    named: impl Iterator<Item = &'a [(u32, u32, u32)]>,
547    codec: u8,
548) -> Vec<u8> {
549    use crate::terms::{is_literal, literal_lexical};
550    // Resolve the listed predicates to ids once; an IRI the graph never uses as
551    // a predicate has no id and can match nothing.
552    let listed: Vec<Option<u32>> = scope
553        .listed_predicates()
554        .iter()
555        .map(|p| dict.predicate_id(p))
556        .collect();
557    let mut seen = vec![false; listed.len()];
558    let mut covered = |p: u32| -> bool {
559        let hit = listed.iter().position(|id| *id == Some(p));
560        if let Some(i) = hit {
561            seen[i] = true;
562        }
563        match scope.predicates {
564            TextIndexPredicates::All => true,
565            TextIndexPredicates::Only(_) => hit.is_some(),
566            TextIndexPredicates::Except(_) => hit.is_none(),
567        }
568    };
569    let mut b = TextIndexBuilder::with_scope(scope.clone());
570    let named: Vec<&[(u32, u32, u32)]> = if scope.covers_named_graphs() {
571        named.collect()
572    } else {
573        Vec::new()
574    };
575    for triples in std::iter::once(default).chain(named) {
576        for &(s, p, o) in triples {
577            let Some(term) = dict.object_term(o) else {
578                continue;
579            };
580            if !is_literal(&term) || !covered(p) {
581                continue;
582            }
583            if let Some(lit) = literal_lexical(&term) {
584                b.add_text(&lit, s);
585            }
586        }
587    }
588    warn_unmatched(scope, &seen);
589    if b.is_empty() {
590        Vec::new()
591    } else {
592        b.build(codec)
593    }
594}
595
596/// Report each `--text-index-include`/`--text-index-exclude` predicate that no
597/// in-scope literal carried (`seen[i]` for `scope.listed_predicates()[i]`).
598pub(crate) fn warn_unmatched(scope: &TextIndexScope, seen: &[bool]) {
599    let flag = match scope.predicates {
600        TextIndexPredicates::All => return,
601        TextIndexPredicates::Only(_) => "--text-index-include",
602        TextIndexPredicates::Except(_) => "--text-index-exclude",
603    };
604    for (p, _) in scope
605        .listed_predicates()
606        .iter()
607        .zip(seen)
608        .filter(|(_, s)| !**s)
609    {
610        eprintln!(
611            "warning: {flag} {p}: no literal object with this predicate in the indexed graphs"
612        );
613    }
614}
615
616// ---------------------------------------------------------------------------
617// Reading
618// ---------------------------------------------------------------------------
619
620/// Where a [`TextIndex`] reads posting lists from.
621enum Postings {
622    /// The whole postings blob, resident (local / fully-loaded reads).
623    Resident(Vec<u8>),
624    /// Fetch one posting `(offset_within_blob, len)` on demand (remote reads).
625    Remote(Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>),
626}
627
628/// A parsed text index: the token table (always resident) plus a posting source.
629pub struct TextIndex {
630    /// `(token, posting_offset, posting_len)`, sorted by token.
631    tokens: Vec<(String, u64, u64)>,
632    postings: Postings,
633    scope: TextIndexScope,
634}
635
636impl TextIndex {
637    /// Parse a whole section (local): token table + the full postings blob resident.
638    pub fn from_section(section: &[u8], codec: u8) -> Result<Self, FileError> {
639        let (tokens, scope, postings_start) = parse_token_table(section, codec)?;
640        let postings = section
641            .get(postings_start..)
642            .ok_or(FileError::Container("text-index postings overrun"))?
643            .to_vec();
644        Ok(TextIndex {
645            tokens,
646            postings: Postings::Resident(postings),
647            scope,
648        })
649    }
650
651    /// Build from a section **prefix** holding the token table, plus a loader that
652    /// fetches a posting `(offset_within_postings_blob, len)` on demand (remote).
653    pub fn from_token_table(
654        prefix: &[u8],
655        codec: u8,
656        loader: Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>,
657    ) -> Result<Self, FileError> {
658        let (tokens, scope, _postings_start) = parse_token_table(prefix, codec)?;
659        Ok(TextIndex {
660            tokens,
661            postings: Postings::Remote(loader),
662            scope,
663        })
664    }
665
666    /// Byte offset of the postings blob within the section (token-table end).
667    /// The remote opener uses this to base its posting-range loader.
668    pub fn postings_base(section_prefix: &[u8]) -> Option<usize> {
669        let (len, n) = read_uvarint(section_prefix)?;
670        Some(n + len as usize)
671    }
672
673    /// Number of distinct tokens.
674    pub fn token_count(&self) -> usize {
675        self.tokens.len()
676    }
677
678    /// What this index covers (the default scope for every pre-0.4.0 file).
679    pub fn scope(&self) -> &TextIndexScope {
680        &self.scope
681    }
682
683    fn posting_for(&self, off: u64, len: u64) -> Option<Vec<u8>> {
684        match &self.postings {
685            Postings::Resident(blob) => blob
686                .get(off as usize..(off + len) as usize)
687                .map(<[u8]>::to_vec),
688            Postings::Remote(load) => load(off, len),
689        }
690    }
691
692    /// Subjects whose literals contain the exact `token` (case-insensitive — the
693    /// caller passes a [`tokenize`]d word), sorted ascending.
694    pub fn lookup(&self, token: &str) -> Vec<u32> {
695        let i = self.tokens.partition_point(|(t, _, _)| t.as_str() < token);
696        match self.tokens.get(i) {
697            Some((t, off, len)) if t == token => self
698                .posting_for(*off, *len)
699                .map(|b| decode_postings(&b))
700                .unwrap_or_default(),
701            _ => Vec::new(),
702        }
703    }
704
705    /// Subjects whose literals contain a word that CONTAINS `piece` as a
706    /// substring — the union over every matching table token. The token table
707    /// is always resident so the scan is in-memory; each matching token costs
708    /// one posting read (a range fetch on the remote path), so an unselective
709    /// piece matching more than [`SUBSTRING_TOKEN_CAP`] tokens returns `None`
710    /// and the caller falls back to its non-indexed path.
711    pub fn substring(&self, piece: &str) -> Option<Vec<u32>> {
712        let matching: Vec<&(String, u64, u64)> = self
713            .tokens
714            .iter()
715            .filter(|(t, _, _)| t.contains(piece))
716            .collect();
717        if matching.len() > SUBSTRING_TOKEN_CAP {
718            return None;
719        }
720        let mut out = BTreeSet::new();
721        for (_, off, len) in matching {
722            if let Some(b) = self.posting_for(*off, *len) {
723                out.extend(decode_postings(&b));
724            }
725        }
726        Some(out.into_iter().collect())
727    }
728
729    /// Subjects whose literals contain a word **starting with** `prefix` — the
730    /// union over every token in the `prefix…` range. Sorted, deduped.
731    pub fn prefix(&self, prefix: &str) -> Vec<u32> {
732        let start = self.tokens.partition_point(|(t, _, _)| t.as_str() < prefix);
733        let mut out = BTreeSet::new();
734        for (t, off, len) in &self.tokens[start..] {
735            if !t.starts_with(prefix) {
736                break;
737            }
738            if let Some(b) = self.posting_for(*off, *len) {
739                out.extend(decode_postings(&b));
740            }
741        }
742        out.into_iter().collect()
743    }
744}
745
746/// Parse the token table from a section (or a prefix covering it). Returns the
747/// `(token, off, len)` list, the scope, and the byte offset where the postings
748/// blob begins.
749#[allow(clippy::type_complexity)]
750fn parse_token_table(
751    bytes: &[u8],
752    codec: u8,
753) -> Result<(Vec<(String, u64, u64)>, TextIndexScope, usize), FileError> {
754    let (ttlen, n) = read_uvarint(bytes).ok_or(FileError::Container("truncated text-index len"))?;
755    let end = n
756        .checked_add(ttlen as usize)
757        .filter(|&e| e <= bytes.len())
758        .ok_or(FileError::Container("text-index token table overruns"))?;
759    let tt = decompress(codec, &bytes[n..end])?;
760
761    let mut pos = 0usize;
762    let take = |pos: &mut usize| -> Option<u64> {
763        let (v, k) = read_uvarint(tt.get(*pos..)?)?;
764        *pos += k;
765        Some(v)
766    };
767    let count = take(&mut pos).ok_or(FileError::Container("truncated token count"))? as usize;
768    let mut tokens: Vec<(String, u64, u64)> = Vec::with_capacity(count.min(tt.len()));
769    let mut prev = String::new();
770    for _ in 0..count {
771        let shared = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
772        let slen = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
773        let sstart = pos;
774        let send = sstart
775            .checked_add(slen)
776            .filter(|&e| e <= tt.len())
777            .ok_or(FileError::Container("token suffix overruns"))?;
778        let suffix = &tt[sstart..send];
779        pos = send;
780        let off = take(&mut pos).ok_or(FileError::Container("truncated posting off"))?;
781        let len = take(&mut pos).ok_or(FileError::Container("truncated posting len"))?;
782        let base = prev.get(..shared.min(prev.len())).unwrap_or(&prev);
783        let mut token = String::with_capacity(base.len() + suffix.len());
784        token.push_str(base);
785        token.push_str(
786            std::str::from_utf8(suffix).map_err(|_| FileError::Container("bad token utf8"))?,
787        );
788        tokens.push((token.clone(), off, len));
789        prev = token;
790    }
791    let scope = TextIndexScope::decode(tt.get(pos..).unwrap_or_default())?;
792    Ok((tokens, scope, end))
793}
794
795/// Decode a posting list: `count` then `count` delta-varint ascending subject ids.
796fn decode_postings(bytes: &[u8]) -> Vec<u32> {
797    let mut pos = 0usize;
798    let take = |pos: &mut usize| -> Option<u64> {
799        let (v, k) = read_uvarint(bytes.get(*pos..)?)?;
800        *pos += k;
801        Some(v)
802    };
803    let Some(count) = take(&mut pos) else {
804        return Vec::new();
805    };
806    let mut out = Vec::with_capacity((count as usize).min(bytes.len()));
807    let mut prev = 0u32;
808    for _ in 0..count {
809        let Some(d) = take(&mut pos) else { break };
810        prev = prev.wrapping_add(d as u32);
811        out.push(prev);
812    }
813    out
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819    use crate::CODEC_NONE;
820
821    #[test]
822    fn tokenize_splits_lowercases_and_drops_short() {
823        // Splits on non-alphanumerics, lowercases, drops 1-char tokens ("D", "6", "β").
824        let got: Vec<String> = tokenize("Alpha-D-Glucose, 6-phosphate (β)").collect();
825        assert_eq!(
826            got.iter().map(String::as_str).collect::<Vec<_>>(),
827            ["alpha", "glucose", "phosphate"]
828        );
829    }
830
831    #[test]
832    fn round_trip_lookup_prefix_and_and() {
833        let mut b = TextIndexBuilder::new();
834        b.add_text("alpha-D-glucose", 10);
835        b.add_text("glucose 6-phosphate", 11);
836        b.add_text("benzene ring", 12);
837        let bytes = b.build(CODEC_NONE);
838        let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
839
840        assert_eq!(idx.lookup("glucose"), vec![10, 11]);
841        assert_eq!(idx.lookup("benzene"), vec![12]);
842        assert!(idx.lookup("missing").is_empty());
843        // prefix unions tokens (alpha, …) — here just "alpha".
844        assert_eq!(idx.prefix("alph"), vec![10]);
845        // token-prefix that spans several: "ph" → "phosphate".
846        assert_eq!(idx.prefix("phos"), vec![11]);
847        assert!(idx.scope().is_default());
848    }
849
850    #[test]
851    fn remote_loader_fetches_only_the_queried_posting() {
852        use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
853        use std::sync::Arc;
854        let mut b = TextIndexBuilder::new();
855        b.add_text("glucose here", 1);
856        b.add_text("benzene there", 2);
857        let bytes = b.build(CODEC_NONE);
858        let base = TextIndex::postings_base(&bytes).unwrap();
859        let postings = bytes[base..].to_vec();
860
861        let calls = Arc::new(AtomicUsize::new(0));
862        let (p, c) = (postings.clone(), calls.clone());
863        let loader = Box::new(move |off: u64, len: u64| {
864            c.fetch_add(1, SeqCst);
865            p.get(off as usize..(off + len) as usize)
866                .map(<[u8]>::to_vec)
867        });
868        // Token table prefix = everything up to the postings blob.
869        let idx = TextIndex::from_token_table(&bytes[..base], CODEC_NONE, loader).unwrap();
870        assert_eq!(idx.lookup("glucose"), vec![1]);
871        assert_eq!(calls.load(SeqCst), 1, "one posting fetched");
872        assert!(idx.lookup("nope").is_empty());
873        assert_eq!(calls.load(SeqCst), 1, "an absent token fetches nothing");
874    }
875
876    /// The byte-level prefix rule must agree with `str::is_char_boundary`, or
877    /// the spilled build (bytes) and the in-RAM build (`String`s) would
878    /// front-code multi-byte tokens differently.
879    #[test]
880    fn shared_prefix_on_bytes_matches_the_str_rule() {
881        let words = [
882            "",
883            "a",
884            "ab",
885            "abc",
886            "é",
887            "éa",
888            "éé",
889            "eé",
890            "ü",
891            "ünïcode",
892            "ünïcodé",
893            "日本",
894            "日本語",
895            "日",
896            "z",
897            "zß",
898            "zss",
899        ];
900        for a in words {
901            for b in words {
902                let mut n = a
903                    .as_bytes()
904                    .iter()
905                    .zip(b.as_bytes())
906                    .take_while(|(x, y)| x == y)
907                    .count();
908                while n > 0 && !b.is_char_boundary(n) {
909                    n -= 1;
910                }
911                assert_eq!(
912                    shared_prefix(a.as_bytes(), b.as_bytes()),
913                    n,
914                    "{a:?} vs {b:?}"
915                );
916            }
917        }
918    }
919
920    /// The pinned bytes of a default-scope section: what every build wrote
921    /// before scopes existed, so a refactor of the writer cannot move them.
922    #[test]
923    fn default_scope_section_bytes_are_pinned() {
924        let mut b = TextIndexBuilder::new();
925        b.add_text("ab ac", 1);
926        b.add_text("ab", 3);
927        let bytes = b.build(CODEC_NONE);
928        // tt = [2 tokens] [0,2,"ab",off 0,len 3] [1,1,"c",off 3,len 2]
929        // postings = [2, 1, 2] [1, 1]
930        let tt: &[u8] = &[2, 0, 2, b'a', b'b', 0, 3, 1, 1, b'c', 3, 2];
931        let mut want = vec![tt.len() as u8];
932        want.extend_from_slice(tt);
933        want.extend_from_slice(&[2, 1, 2, 1, 1]);
934        assert_eq!(bytes, want);
935    }
936
937    #[test]
938    fn scope_block_round_trips_and_default_writes_none() {
939        let mut tt = Vec::new();
940        TextIndexScope::default().encode(&mut tt);
941        assert!(tt.is_empty(), "the default scope writes no block");
942
943        let scopes = [
944            TextIndexScope {
945                graphs: TextIndexGraphs::All,
946                predicates: TextIndexPredicates::All,
947            },
948            TextIndexScope::from_options(
949                TextIndexGraphs::Default,
950                Vec::<String>::new(),
951                ["http://purl.org/dc/terms/description", "<http://ex/b>"],
952            )
953            .unwrap(),
954            TextIndexScope::from_options(
955                TextIndexGraphs::All,
956                ["http://ex/z", "http://ex/a", "http://ex/z"],
957                Vec::<String>::new(),
958            )
959            .unwrap(),
960        ];
961        for scope in scopes {
962            let mut b = TextIndexBuilder::with_scope(scope.clone());
963            b.add_text("word", 1);
964            let bytes = b.build(CODEC_NONE);
965            let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
966            assert_eq!(idx.scope(), &scope);
967            assert_eq!(idx.lookup("word"), vec![1]);
968            // The remote path parses the same table prefix to the same scope.
969            let base = TextIndex::postings_base(&bytes).unwrap();
970            let remote =
971                TextIndex::from_token_table(&bytes[..base], CODEC_NONE, Box::new(|_, _| None))
972                    .unwrap();
973            assert_eq!(remote.scope(), &scope);
974        }
975    }
976
977    #[test]
978    fn scope_options_normalize_and_validate() {
979        let s = TextIndexScope::from_options(
980            TextIndexGraphs::Default,
981            Vec::<String>::new(),
982            ["<http://ex/b>", "http://ex/a", "http://ex/b"],
983        )
984        .unwrap();
985        assert_eq!(
986            s.predicates,
987            TextIndexPredicates::Except(vec!["<http://ex/a>".into(), "<http://ex/b>".into()])
988        );
989        assert!(s.restricts_predicates());
990        assert!(!s.covers_predicate("<http://ex/a>"));
991        assert!(s.covers_predicate("<http://ex/c>"));
992        assert!(s.covers_pattern_predicate(Some("<http://ex/c>")));
993        assert!(
994            !s.covers_pattern_predicate(None),
995            "a variable predicate can bind ex:a"
996        );
997
998        let only = TextIndexScope::from_options(
999            TextIndexGraphs::All,
1000            ["http://ex/a"],
1001            Vec::<String>::new(),
1002        )
1003        .unwrap();
1004        assert!(only.covers_predicate("<http://ex/a>"));
1005        assert!(!only.covers_predicate("<http://ex/b>"));
1006        assert!(only.covers_named_graphs());
1007
1008        let all = TextIndexScope::default();
1009        assert!(all.covers_pattern_predicate(None));
1010        assert!(!all.restricts_predicates());
1011
1012        for bad in ["description", "", "http://ex/a b", "<http://ex/a", ":x"] {
1013            assert!(
1014                TextIndexScope::from_options(TextIndexGraphs::Default, [bad], Vec::<String>::new())
1015                    .is_err(),
1016                "{bad:?} should be refused"
1017            );
1018        }
1019        assert!(TextIndexScope::from_options(
1020            TextIndexGraphs::Default,
1021            ["http://ex/a"],
1022            ["http://ex/b"]
1023        )
1024        .is_err());
1025    }
1026
1027    /// A scope block of a later version must not make the file unreadable, and
1028    /// must not be read as covering anything the SPARQL pushdown could prune on.
1029    #[test]
1030    fn unknown_scope_version_covers_no_predicate() {
1031        let s = TextIndexScope::decode(&[9, 1, 2, 3]).unwrap();
1032        assert!(!s.covers_predicate("<http://ex/a>"));
1033        assert!(!s.covers_pattern_predicate(None));
1034        // A truncated v1 block is malformed, not silently the default.
1035        assert!(TextIndexScope::decode(&[1, 0]).is_err());
1036        assert!(TextIndexScope::decode(&[1, 0, 1, 1, 5, b'<']).is_err());
1037    }
1038}