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    pub(crate) 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/// One leaf of a `0x06` token table, as the root describes it.
629#[derive(Debug, Clone)]
630pub struct TokenLeaf {
631    /// Index of the leaf's first token.
632    pub first: usize,
633    /// Tokens in the leaf.
634    pub n: usize,
635    /// The leaf's first token.
636    pub first_token: String,
637    /// Absolute range of the stored leaf block.
638    pub leaf: (u64, u64),
639}
640
641/// A token table whose entries live in leaf blocks faulted on demand (format
642/// `0x06`): a word lookup faults one leaf, a prefix the leaves of its run, and
643/// only a substring search (`CONTAINS` pushdown) needs them all.
644/// A faulted token-table leaf: each token and its posting range.
645type TokenCell = std::sync::OnceLock<Box<[(String, u64, u64)]>>;
646
647pub struct LeafedTokens {
648    leaves: Vec<TokenLeaf>,
649    cells: Vec<TokenCell>,
650    total: usize,
651    fetch: crate::index::LeafFetch,
652    codec: u8,
653    /// Decompression bound on a leaf.
654    max_block: u64,
655    failed: std::sync::atomic::AtomicBool,
656}
657
658impl LeafedTokens {
659    /// Bound every leaf decompression at `max` bytes (from the `0x06` root).
660    pub fn with_max_block(mut self, max: u64) -> Self {
661        self.max_block = max;
662        self
663    }
664
665    /// A leafed token table of `total` tokens.
666    pub fn new(
667        leaves: Vec<TokenLeaf>,
668        total: usize,
669        fetch: crate::index::LeafFetch,
670        codec: u8,
671    ) -> Self {
672        let cells = leaves.iter().map(|_| std::sync::OnceLock::new()).collect();
673        LeafedTokens {
674            leaves,
675            cells,
676            total,
677            fetch,
678            codec,
679            max_block: u64::MAX,
680            failed: std::sync::atomic::AtomicBool::new(false),
681        }
682    }
683
684    fn decode(&self, li: usize, stored: &[u8]) -> Option<Box<[(String, u64, u64)]>> {
685        let raw = crate::file::decompress_bounded(self.codec, stored, self.max_block).ok()?;
686        let ents = crate::v06::decode_token_leaf(&raw, self.leaves[li].n).ok()?;
687        ents.into_iter()
688            .map(|e| Some((String::from_utf8(e.token).ok()?, e.off, e.len)))
689            .collect::<Option<Vec<_>>>()
690            .map(Vec::into_boxed_slice)
691    }
692
693    fn ensure(&self, lis: &[usize]) {
694        // Callers pass one leaf per pattern or term, so the same leaf can come
695        // many times over (a join probing 460 k subjects of one leaf): fetch
696        // each leaf once.
697        let mut missing: Vec<usize> = lis
698            .iter()
699            .copied()
700            .filter(|&li| li < self.cells.len() && self.cells[li].get().is_none())
701            .collect();
702        missing.sort_unstable();
703        missing.dedup();
704        if missing.is_empty() {
705            return;
706        }
707        let ranges: Vec<(u64, u64)> = missing.iter().map(|&li| self.leaves[li].leaf).collect();
708        match (self.fetch)(&ranges) {
709            Some(blobs) if blobs.len() == missing.len() => {
710                for (&li, b) in missing.iter().zip(blobs) {
711                    match self.decode(li, &b) {
712                        Some(v) => {
713                            let _ = self.cells[li].set(v);
714                        }
715                        None => self
716                            .failed
717                            .store(true, std::sync::atomic::Ordering::Relaxed),
718                    }
719                }
720            }
721            _ => self
722                .failed
723                .store(true, std::sync::atomic::Ordering::Relaxed),
724        }
725    }
726
727    fn leaf(&self, li: usize) -> &[(String, u64, u64)] {
728        self.ensure(&[li]);
729        self.cells[li].get().map_or(&[], |b| &b[..])
730    }
731
732    /// The leaf a token routes to (by first token).
733    fn leaf_for(&self, token: &str) -> usize {
734        self.leaves
735            .partition_point(|l| l.first_token.as_str() <= token)
736            .saturating_sub(1)
737    }
738}
739
740/// A token table: resident (`0x05`, local opens) or leafed (`0x06`).
741enum Tokens {
742    Full(Vec<(String, u64, u64)>),
743    Leafed(std::sync::Arc<LeafedTokens>),
744}
745
746/// A parsed text index: the token table (resident, or leafed and faulted by
747/// leaf) plus a posting source.
748pub struct TextIndex {
749    /// `(token, posting_offset, posting_len)`, sorted by token.
750    tokens: Tokens,
751    postings: Postings,
752    scope: TextIndexScope,
753}
754
755impl TextIndex {
756    /// Parse a whole section (local): token table + the full postings blob resident.
757    pub fn from_section(section: &[u8], codec: u8) -> Result<Self, FileError> {
758        let (tokens, scope, postings_start) = parse_token_table(section, codec)?;
759        let postings = section
760            .get(postings_start..)
761            .ok_or(FileError::Container("text-index postings overrun"))?
762            .to_vec();
763        Ok(TextIndex {
764            tokens: Tokens::Full(tokens),
765            postings: Postings::Resident(postings),
766            scope,
767        })
768    }
769
770    /// Build from a section **prefix** holding the token table, plus a loader that
771    /// fetches a posting `(offset_within_postings_blob, len)` on demand (remote).
772    pub fn from_token_table(
773        prefix: &[u8],
774        codec: u8,
775        loader: Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>,
776    ) -> Result<Self, FileError> {
777        let (tokens, scope, _postings_start) = parse_token_table(prefix, codec)?;
778        Ok(TextIndex {
779            tokens: Tokens::Full(tokens),
780            postings: Postings::Remote(loader),
781            scope,
782        })
783    }
784
785    /// A `0x06` text index: a leafed token table and a posting loader
786    /// (offsets within the postings blob).
787    pub fn leafed(
788        tokens: std::sync::Arc<LeafedTokens>,
789        scope_block: &[u8],
790        loader: Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>,
791    ) -> Result<Self, FileError> {
792        Ok(TextIndex {
793            tokens: Tokens::Leafed(tokens),
794            postings: Postings::Remote(loader),
795            scope: TextIndexScope::decode(scope_block)?,
796        })
797    }
798
799    /// Did a token-table leaf fault fail?
800    pub fn load_failed(&self) -> bool {
801        matches!(&self.tokens, Tokens::Leafed(l) if l.failed.load(std::sync::atomic::Ordering::Relaxed))
802    }
803
804    /// Byte offset of the postings blob within the section (token-table end).
805    /// The remote opener uses this to base its posting-range loader.
806    pub fn postings_base(section_prefix: &[u8]) -> Option<usize> {
807        let (len, n) = read_uvarint(section_prefix)?;
808        Some(n + len as usize)
809    }
810
811    /// Number of distinct tokens.
812    pub fn token_count(&self) -> usize {
813        match &self.tokens {
814            Tokens::Full(t) => t.len(),
815            Tokens::Leafed(l) => l.total,
816        }
817    }
818
819    /// What this index covers (the default scope for every pre-0.4.0 file).
820    pub fn scope(&self) -> &TextIndexScope {
821        &self.scope
822    }
823
824    fn posting_for(&self, off: u64, len: u64) -> Option<Vec<u8>> {
825        match &self.postings {
826            Postings::Resident(blob) => blob
827                .get(off as usize..(off + len) as usize)
828                .map(<[u8]>::to_vec),
829            Postings::Remote(load) => load(off, len),
830        }
831    }
832
833    /// Subjects whose literals contain the exact `token` (case-insensitive — the
834    /// caller passes a [`tokenize`]d word), sorted ascending.
835    pub fn lookup(&self, token: &str) -> Vec<u32> {
836        let found = match &self.tokens {
837            Tokens::Full(tokens) => {
838                let i = tokens.partition_point(|(t, _, _)| t.as_str() < token);
839                tokens.get(i).cloned()
840            }
841            Tokens::Leafed(l) => {
842                let leaf = l.leaf(l.leaf_for(token));
843                let i = leaf.partition_point(|(t, _, _)| t.as_str() < token);
844                leaf.get(i).cloned()
845            }
846        };
847        match found {
848            Some((t, off, len)) if t == token => self
849                .posting_for(off, len)
850                .map(|b| decode_postings(&b))
851                .unwrap_or_default(),
852            _ => Vec::new(),
853        }
854    }
855
856    /// Every token, in order (a leafed table faults all its leaves at once).
857    fn all_tokens(&self) -> Vec<&(String, u64, u64)> {
858        match &self.tokens {
859            Tokens::Full(t) => t.iter().collect(),
860            Tokens::Leafed(l) => {
861                l.ensure(&(0..l.leaves.len()).collect::<Vec<_>>());
862                l.cells
863                    .iter()
864                    .filter_map(|c| c.get())
865                    .flat_map(|b| b.iter())
866                    .collect()
867            }
868        }
869    }
870
871    /// Subjects whose literals contain a word that CONTAINS `piece` as a
872    /// substring — the union over every matching table token. The token table
873    /// is always resident so the scan is in-memory; each matching token costs
874    /// one posting read (a range fetch on the remote path), so an unselective
875    /// piece matching more than [`SUBSTRING_TOKEN_CAP`] tokens returns `None`
876    /// and the caller falls back to its non-indexed path.
877    pub fn substring(&self, piece: &str) -> Option<Vec<u32>> {
878        let matching: Vec<&(String, u64, u64)> = self
879            .all_tokens()
880            .into_iter()
881            .filter(|(t, _, _)| t.contains(piece))
882            .collect();
883        if matching.len() > SUBSTRING_TOKEN_CAP {
884            return None;
885        }
886        let mut out = BTreeSet::new();
887        for (_, off, len) in matching {
888            if let Some(b) = self.posting_for(*off, *len) {
889                out.extend(decode_postings(&b));
890            }
891        }
892        Some(out.into_iter().collect())
893    }
894
895    /// Subjects whose literals contain a word **starting with** `prefix` — the
896    /// union over every token in the `prefix…` range. Sorted, deduped.
897    pub fn prefix(&self, prefix: &str) -> Vec<u32> {
898        let mut hits: Vec<(u64, u64)> = Vec::new();
899        match &self.tokens {
900            Tokens::Full(tokens) => {
901                let start = tokens.partition_point(|(t, _, _)| t.as_str() < prefix);
902                for (t, off, len) in &tokens[start..] {
903                    if !t.starts_with(prefix) {
904                        break;
905                    }
906                    hits.push((*off, *len));
907                }
908            }
909            Tokens::Leafed(l) => {
910                let mut li = l.leaf_for(prefix);
911                'leaves: while li < l.leaves.len() {
912                    let leaf = l.leaf(li);
913                    let start = leaf.partition_point(|(t, _, _)| t.as_str() < prefix);
914                    for (t, off, len) in &leaf[start..] {
915                        if !t.starts_with(prefix) {
916                            break 'leaves;
917                        }
918                        hits.push((*off, *len));
919                    }
920                    li += 1;
921                    if li < l.leaves.len()
922                        && !l.leaves[li].first_token.starts_with(prefix)
923                        && l.leaves[li].first_token.as_str() > prefix
924                    {
925                        break;
926                    }
927                }
928            }
929        }
930        let mut out = BTreeSet::new();
931        for (off, len) in hits {
932            if let Some(b) = self.posting_for(off, len) {
933                out.extend(decode_postings(&b));
934            }
935        }
936        out.into_iter().collect()
937    }
938}
939
940/// [`parse_token_table`] that also returns the raw scope block (empty for
941/// the default scope) — what a `0x06` root stores.
942#[allow(clippy::type_complexity)]
943pub(crate) fn parse_token_table_raw(
944    bytes: &[u8],
945    codec: u8,
946) -> Result<(Vec<(String, u64, u64)>, TextIndexScope, Vec<u8>, usize), FileError> {
947    let (tokens, scope, end) = parse_token_table(bytes, codec)?;
948    let mut raw = Vec::new();
949    if !scope.is_default() {
950        scope.encode(&mut raw);
951    }
952    Ok((tokens, scope, raw, end))
953}
954
955/// Parse the token table from a section (or a prefix covering it). Returns the
956/// `(token, off, len)` list, the scope, and the byte offset where the postings
957/// blob begins.
958#[allow(clippy::type_complexity)]
959fn parse_token_table(
960    bytes: &[u8],
961    codec: u8,
962) -> Result<(Vec<(String, u64, u64)>, TextIndexScope, usize), FileError> {
963    let (ttlen, n) = read_uvarint(bytes).ok_or(FileError::Container("truncated text-index len"))?;
964    let end = n
965        .checked_add(ttlen as usize)
966        .filter(|&e| e <= bytes.len())
967        .ok_or(FileError::Container("text-index token table overruns"))?;
968    let tt = decompress(codec, &bytes[n..end])?;
969
970    let mut pos = 0usize;
971    let take = |pos: &mut usize| -> Option<u64> {
972        let (v, k) = read_uvarint(tt.get(*pos..)?)?;
973        *pos += k;
974        Some(v)
975    };
976    let count = take(&mut pos).ok_or(FileError::Container("truncated token count"))? as usize;
977    let mut tokens: Vec<(String, u64, u64)> = Vec::with_capacity(count.min(tt.len()));
978    let mut prev = String::new();
979    for _ in 0..count {
980        let shared = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
981        let slen = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
982        let sstart = pos;
983        let send = sstart
984            .checked_add(slen)
985            .filter(|&e| e <= tt.len())
986            .ok_or(FileError::Container("token suffix overruns"))?;
987        let suffix = &tt[sstart..send];
988        pos = send;
989        let off = take(&mut pos).ok_or(FileError::Container("truncated posting off"))?;
990        let len = take(&mut pos).ok_or(FileError::Container("truncated posting len"))?;
991        let base = prev.get(..shared.min(prev.len())).unwrap_or(&prev);
992        let mut token = String::with_capacity(base.len() + suffix.len());
993        token.push_str(base);
994        token.push_str(
995            std::str::from_utf8(suffix).map_err(|_| FileError::Container("bad token utf8"))?,
996        );
997        tokens.push((token.clone(), off, len));
998        prev = token;
999    }
1000    let scope = TextIndexScope::decode(tt.get(pos..).unwrap_or_default())?;
1001    Ok((tokens, scope, end))
1002}
1003
1004/// Decode a posting list: `count` then `count` delta-varint ascending subject ids.
1005fn decode_postings(bytes: &[u8]) -> Vec<u32> {
1006    let mut pos = 0usize;
1007    let take = |pos: &mut usize| -> Option<u64> {
1008        let (v, k) = read_uvarint(bytes.get(*pos..)?)?;
1009        *pos += k;
1010        Some(v)
1011    };
1012    let Some(count) = take(&mut pos) else {
1013        return Vec::new();
1014    };
1015    let mut out = Vec::with_capacity((count as usize).min(bytes.len()));
1016    let mut prev = 0u32;
1017    for _ in 0..count {
1018        let Some(d) = take(&mut pos) else { break };
1019        prev = prev.wrapping_add(d as u32);
1020        out.push(prev);
1021    }
1022    out
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::*;
1028    use crate::CODEC_NONE;
1029
1030    #[test]
1031    fn tokenize_splits_lowercases_and_drops_short() {
1032        // Splits on non-alphanumerics, lowercases, drops 1-char tokens ("D", "6", "β").
1033        let got: Vec<String> = tokenize("Alpha-D-Glucose, 6-phosphate (β)").collect();
1034        assert_eq!(
1035            got.iter().map(String::as_str).collect::<Vec<_>>(),
1036            ["alpha", "glucose", "phosphate"]
1037        );
1038    }
1039
1040    #[test]
1041    fn round_trip_lookup_prefix_and_and() {
1042        let mut b = TextIndexBuilder::new();
1043        b.add_text("alpha-D-glucose", 10);
1044        b.add_text("glucose 6-phosphate", 11);
1045        b.add_text("benzene ring", 12);
1046        let bytes = b.build(CODEC_NONE);
1047        let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
1048
1049        assert_eq!(idx.lookup("glucose"), vec![10, 11]);
1050        assert_eq!(idx.lookup("benzene"), vec![12]);
1051        assert!(idx.lookup("missing").is_empty());
1052        // prefix unions tokens (alpha, …) — here just "alpha".
1053        assert_eq!(idx.prefix("alph"), vec![10]);
1054        // token-prefix that spans several: "ph" → "phosphate".
1055        assert_eq!(idx.prefix("phos"), vec![11]);
1056        assert!(idx.scope().is_default());
1057    }
1058
1059    #[test]
1060    fn remote_loader_fetches_only_the_queried_posting() {
1061        use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
1062        use std::sync::Arc;
1063        let mut b = TextIndexBuilder::new();
1064        b.add_text("glucose here", 1);
1065        b.add_text("benzene there", 2);
1066        let bytes = b.build(CODEC_NONE);
1067        let base = TextIndex::postings_base(&bytes).unwrap();
1068        let postings = bytes[base..].to_vec();
1069
1070        let calls = Arc::new(AtomicUsize::new(0));
1071        let (p, c) = (postings.clone(), calls.clone());
1072        let loader = Box::new(move |off: u64, len: u64| {
1073            c.fetch_add(1, SeqCst);
1074            p.get(off as usize..(off + len) as usize)
1075                .map(<[u8]>::to_vec)
1076        });
1077        // Token table prefix = everything up to the postings blob.
1078        let idx = TextIndex::from_token_table(&bytes[..base], CODEC_NONE, loader).unwrap();
1079        assert_eq!(idx.lookup("glucose"), vec![1]);
1080        assert_eq!(calls.load(SeqCst), 1, "one posting fetched");
1081        assert!(idx.lookup("nope").is_empty());
1082        assert_eq!(calls.load(SeqCst), 1, "an absent token fetches nothing");
1083    }
1084
1085    /// The byte-level prefix rule must agree with `str::is_char_boundary`, or
1086    /// the spilled build (bytes) and the in-RAM build (`String`s) would
1087    /// front-code multi-byte tokens differently.
1088    #[test]
1089    fn shared_prefix_on_bytes_matches_the_str_rule() {
1090        let words = [
1091            "",
1092            "a",
1093            "ab",
1094            "abc",
1095            "é",
1096            "éa",
1097            "éé",
1098            "eé",
1099            "ü",
1100            "ünïcode",
1101            "ünïcodé",
1102            "日本",
1103            "日本語",
1104            "日",
1105            "z",
1106            "zß",
1107            "zss",
1108        ];
1109        for a in words {
1110            for b in words {
1111                let mut n = a
1112                    .as_bytes()
1113                    .iter()
1114                    .zip(b.as_bytes())
1115                    .take_while(|(x, y)| x == y)
1116                    .count();
1117                while n > 0 && !b.is_char_boundary(n) {
1118                    n -= 1;
1119                }
1120                assert_eq!(
1121                    shared_prefix(a.as_bytes(), b.as_bytes()),
1122                    n,
1123                    "{a:?} vs {b:?}"
1124                );
1125            }
1126        }
1127    }
1128
1129    /// The pinned bytes of a default-scope section: what every build wrote
1130    /// before scopes existed, so a refactor of the writer cannot move them.
1131    #[test]
1132    fn default_scope_section_bytes_are_pinned() {
1133        let mut b = TextIndexBuilder::new();
1134        b.add_text("ab ac", 1);
1135        b.add_text("ab", 3);
1136        let bytes = b.build(CODEC_NONE);
1137        // tt = [2 tokens] [0,2,"ab",off 0,len 3] [1,1,"c",off 3,len 2]
1138        // postings = [2, 1, 2] [1, 1]
1139        let tt: &[u8] = &[2, 0, 2, b'a', b'b', 0, 3, 1, 1, b'c', 3, 2];
1140        let mut want = vec![tt.len() as u8];
1141        want.extend_from_slice(tt);
1142        want.extend_from_slice(&[2, 1, 2, 1, 1]);
1143        assert_eq!(bytes, want);
1144    }
1145
1146    #[test]
1147    fn scope_block_round_trips_and_default_writes_none() {
1148        let mut tt = Vec::new();
1149        TextIndexScope::default().encode(&mut tt);
1150        assert!(tt.is_empty(), "the default scope writes no block");
1151
1152        let scopes = [
1153            TextIndexScope {
1154                graphs: TextIndexGraphs::All,
1155                predicates: TextIndexPredicates::All,
1156            },
1157            TextIndexScope::from_options(
1158                TextIndexGraphs::Default,
1159                Vec::<String>::new(),
1160                ["http://purl.org/dc/terms/description", "<http://ex/b>"],
1161            )
1162            .unwrap(),
1163            TextIndexScope::from_options(
1164                TextIndexGraphs::All,
1165                ["http://ex/z", "http://ex/a", "http://ex/z"],
1166                Vec::<String>::new(),
1167            )
1168            .unwrap(),
1169        ];
1170        for scope in scopes {
1171            let mut b = TextIndexBuilder::with_scope(scope.clone());
1172            b.add_text("word", 1);
1173            let bytes = b.build(CODEC_NONE);
1174            let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
1175            assert_eq!(idx.scope(), &scope);
1176            assert_eq!(idx.lookup("word"), vec![1]);
1177            // The remote path parses the same table prefix to the same scope.
1178            let base = TextIndex::postings_base(&bytes).unwrap();
1179            let remote =
1180                TextIndex::from_token_table(&bytes[..base], CODEC_NONE, Box::new(|_, _| None))
1181                    .unwrap();
1182            assert_eq!(remote.scope(), &scope);
1183        }
1184    }
1185
1186    #[test]
1187    fn scope_options_normalize_and_validate() {
1188        let s = TextIndexScope::from_options(
1189            TextIndexGraphs::Default,
1190            Vec::<String>::new(),
1191            ["<http://ex/b>", "http://ex/a", "http://ex/b"],
1192        )
1193        .unwrap();
1194        assert_eq!(
1195            s.predicates,
1196            TextIndexPredicates::Except(vec!["<http://ex/a>".into(), "<http://ex/b>".into()])
1197        );
1198        assert!(s.restricts_predicates());
1199        assert!(!s.covers_predicate("<http://ex/a>"));
1200        assert!(s.covers_predicate("<http://ex/c>"));
1201        assert!(s.covers_pattern_predicate(Some("<http://ex/c>")));
1202        assert!(
1203            !s.covers_pattern_predicate(None),
1204            "a variable predicate can bind ex:a"
1205        );
1206
1207        let only = TextIndexScope::from_options(
1208            TextIndexGraphs::All,
1209            ["http://ex/a"],
1210            Vec::<String>::new(),
1211        )
1212        .unwrap();
1213        assert!(only.covers_predicate("<http://ex/a>"));
1214        assert!(!only.covers_predicate("<http://ex/b>"));
1215        assert!(only.covers_named_graphs());
1216
1217        let all = TextIndexScope::default();
1218        assert!(all.covers_pattern_predicate(None));
1219        assert!(!all.restricts_predicates());
1220
1221        for bad in ["description", "", "http://ex/a b", "<http://ex/a", ":x"] {
1222            assert!(
1223                TextIndexScope::from_options(TextIndexGraphs::Default, [bad], Vec::<String>::new())
1224                    .is_err(),
1225                "{bad:?} should be refused"
1226            );
1227        }
1228        assert!(TextIndexScope::from_options(
1229            TextIndexGraphs::Default,
1230            ["http://ex/a"],
1231            ["http://ex/b"]
1232        )
1233        .is_err());
1234    }
1235
1236    /// A scope block of a later version must not make the file unreadable, and
1237    /// must not be read as covering anything the SPARQL pushdown could prune on.
1238    #[test]
1239    fn unknown_scope_version_covers_no_predicate() {
1240        let s = TextIndexScope::decode(&[9, 1, 2, 3]).unwrap();
1241        assert!(!s.covers_predicate("<http://ex/a>"));
1242        assert!(!s.covers_pattern_predicate(None));
1243        // A truncated v1 block is malformed, not silently the default.
1244        assert!(TextIndexScope::decode(&[1, 0]).is_err());
1245        assert!(TextIndexScope::decode(&[1, 0, 1, 1, 5, b'<']).is_err());
1246    }
1247}