Skip to main content

rete_core/
dictionary.rs

1//! Dictionary assembly: classify RDF terms by role and assign the HDT-style
2//! global IDs that triple blocks reference (SPEC.md §5, §5.1).
3//!
4//! Four sections are built from the §5.1 front-coded primitive:
5//! `shared` (used as both subject and object), `subjects` (subject-only),
6//! `objects` (object-only), and `predicates`. Subject IDs run `1..=S` (shared)
7//! then `S+1..` (subject-only); object IDs reuse `1..=S` for the same shared
8//! terms then `S+1..` for object-only. Predicates have their own space.
9
10use std::collections::{HashMap, HashSet};
11
12use crate::chunk_cache::ChunkCache;
13use crate::dict::{ChunkedSection, DictSectionBuilder, MultiChunkBulkLoader};
14use crate::terms::{NodeId, ObjectId, PredicateId, SubjectId};
15
16/// Sort a term slice ascending — in parallel when the `parallel` feature is on.
17/// The front-coded dictionary sections need ascending order; doing it once here
18/// (rather than per-insert in a `BTreeSet`) is the fast path for large graphs.
19#[cfg(feature = "parallel")]
20fn sort_terms(v: &mut [String]) {
21    use rayon::slice::ParallelSliceMut;
22    v.par_sort_unstable();
23}
24#[cfg(not(feature = "parallel"))]
25fn sort_terms(v: &mut [String]) {
26    v.sort_unstable();
27}
28
29/// Builds a [`Dictionary`] from observed `(subject, predicate, object)` terms.
30///
31/// Terms are deduped with `HashSet` during ingest (O(1) average insert, no tree
32/// rebalancing) and sorted **once** in [`Self::build`]. This replaces a trio of
33/// `BTreeSet<String>` whose per-insert `O(log n)` cost + a `String` allocation
34/// for *every* observed term (most of them duplicates) dominated the build on
35/// large graphs.
36#[derive(Default)]
37pub struct DictionaryBuilder {
38    subjects: HashSet<String>,
39    objects: HashSet<String>,
40    predicates: HashSet<String>,
41    /// Frequent-object promotion ([`Self::with_frequent_objects`]): how many of
42    /// the most frequent object-only terms move into the shared section.
43    promote: usize,
44    /// Object occurrence counts, kept **instead of** `objects` while promotion
45    /// is on (so a default build pays nothing for it).
46    object_counts: HashMap<String, u64>,
47    /// Count objects without promoting any ([`Self::with_object_counts`]).
48    count_objects: bool,
49    /// Entries in the three term sets — a term once per set it is in — and
50    /// their string bytes ([`Self::footprint`]).
51    entries: u64,
52    entry_bytes: u64,
53}
54
55impl DictionaryBuilder {
56    /// A builder with the process-wide writer settings
57    /// ([`crate::write_opts::frequent_objects`]).
58    pub fn new() -> Self {
59        Self::default().with_frequent_objects(crate::write_opts::frequent_objects())
60    }
61
62    /// Promote the `n` most frequent **object-only** terms (by the number of
63    /// statements that carry them as object; ties by term order) into the
64    /// shared section (`0` = off, the default).
65    ///
66    /// The shared section is numbered first and in term order, so a promoted
67    /// vocabulary term (a class, a scheme individual, a status literal) gets a
68    /// small object id wherever it sorts ahead of the shared entities — the
69    /// effect declaring the vocabulary in a TBox has, without the TBox. A
70    /// promoted term keeps its object id role; the subject id it also gets is
71    /// simply never used by any statement. Readers need nothing new: the shared
72    /// section has always been looked up first for both roles.
73    ///
74    /// Set it before the first [`Self::observe`]: promotion counts objects as
75    /// they are observed.
76    pub fn with_frequent_objects(mut self, n: usize) -> Self {
77        debug_assert!(
78            self.objects.is_empty() && self.object_counts.is_empty(),
79            "with_frequent_objects after observe"
80        );
81        self.promote = n;
82        self
83    }
84
85    /// Count every object's occurrences, as promotion does, but promote
86    /// nothing: [`Self::build_counted`] hands the counts of the object-only
87    /// terms back instead. The memory-bounded build's chunks use it, because
88    /// a term's frequency is only known once every chunk's counts are summed
89    /// (`crate::extbuild`).
90    // Only the memory-bounded build (`crate::extbuild`, not on wasm32) uses it.
91    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
92    pub(crate) fn with_object_counts(mut self) -> Self {
93        debug_assert!(
94            self.objects.is_empty() && self.object_counts.is_empty(),
95            "with_object_counts after observe"
96        );
97        self.count_objects = true;
98        self
99    }
100
101    /// Whether objects are kept with their counts rather than as a set.
102    fn counting(&self) -> bool {
103        self.promote > 0 || self.count_objects
104    }
105
106    /// `(entries, bytes)`: the entries of the three term sets observed so far
107    /// — a term once for every set it is in (subject, object, predicate) — and
108    /// the bytes of their strings. What the memory-bounded build charges a
109    /// chunk's dictionary against its budget, since [`Self::build`] costs a
110    /// multiple of each entry.
111    // Only the memory-bounded build (`crate::extbuild`, not on wasm32) uses it.
112    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
113    pub(crate) fn footprint(&self) -> (u64, u64) {
114        (self.entries, self.entry_bytes)
115    }
116
117    fn note_entry(&mut self, term: &str) {
118        self.entries += 1;
119        self.entry_bytes += term.len() as u64;
120    }
121
122    /// Record one triple's terms (IDs are assigned later, in [`Self::build`]).
123    /// We probe with the borrowed `&str` first and only allocate a `String` for
124    /// a genuinely new term — so a graph of N triples does far fewer than 3N
125    /// allocations (each subject/predicate recurs across many triples).
126    pub fn observe(&mut self, subject: &str, predicate: &str, object: &str) {
127        if !self.subjects.contains(subject) {
128            self.subjects.insert(subject.to_string());
129            self.note_entry(subject);
130        }
131        if self.counting() {
132            if let Some(n) = self.object_counts.get_mut(object) {
133                *n += 1;
134            } else {
135                self.object_counts.insert(object.to_string(), 1);
136                self.note_entry(object);
137            }
138        } else if !self.objects.contains(object) {
139            self.objects.insert(object.to_string());
140            self.note_entry(object);
141        }
142        if !self.predicates.contains(predicate) {
143            self.predicates.insert(predicate.to_string());
144            self.note_entry(predicate);
145        }
146    }
147
148    /// Record a **graph name** as a term in the subject role — how the quad
149    /// index (SPEC.md §6.5) makes graph names dictionary ids. It lands in the
150    /// shared section when the name is also an object, else in the
151    /// subject-only section.
152    pub fn observe_graph(&mut self, graph: &str) {
153        if !self.subjects.contains(graph) {
154            self.subjects.insert(graph.to_string());
155            self.note_entry(graph);
156        }
157    }
158
159    pub fn build(self) -> Dictionary {
160        self.build_inner().0
161    }
162
163    /// [`Self::build`] for a builder made [`Self::with_object_counts`]: also
164    /// returns, for every object-only term in the order of the object-only
165    /// section (local ids `1..`), the number of statements that carry it.
166    /// Empty when objects were not counted.
167    // Only the memory-bounded build (`crate::extbuild`, not on wasm32) uses it.
168    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
169    pub(crate) fn build_counted(self) -> (Dictionary, Vec<u64>) {
170        self.build_inner()
171    }
172
173    fn build_inner(self) -> (Dictionary, Vec<u64>) {
174        // Sort each role's terms once (ascending) — identical output order to
175        // the old BTreeSet iteration, but without the per-insert tree cost.
176        let mut subjects: Vec<String> = self.subjects.into_iter().collect();
177        let mut predicates: Vec<String> = self.predicates.into_iter().collect();
178        sort_terms(&mut subjects);
179        sort_terms(&mut predicates);
180
181        // A term is "shared" when it appears as both a subject and an object.
182        let subject_set: HashSet<&str> = subjects.iter().map(String::as_str).collect();
183
184        // The object terms: a plain set, or (promotion on) the count map, used
185        // as-is rather than converted, so no second table over every object.
186        let count_objects = self.count_objects;
187        let object_set = if self.promote > 0 || count_objects {
188            ObjectTerms::Counted(self.object_counts)
189        } else {
190            ObjectTerms::Plain(self.objects)
191        };
192        // Frequent-object promotion: the top `promote` object-only terms by
193        // statement count (ties by term order) join the shared section.
194        let mut promoted: Vec<String> = Vec::new();
195        if let (true, ObjectTerms::Counted(counts)) = (self.promote > 0, &object_set) {
196            let mut top: Vec<(&String, u64)> = counts
197                .iter()
198                .filter(|(t, _)| !subject_set.contains(t.as_str()))
199                .map(|(t, &n)| (t, n))
200                .collect();
201            let k = self.promote.min(top.len());
202            if k > 0 && k < top.len() {
203                top.select_nth_unstable_by(k - 1, |a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
204            }
205            top.truncate(k);
206            promoted = top.into_iter().map(|(t, _)| t.clone()).collect();
207            sort_terms(&mut promoted);
208        }
209        let promoted_set: HashSet<&str> = promoted.iter().map(String::as_str).collect();
210
211        let mut shared_b = DictSectionBuilder::new();
212        let mut subj_b = DictSectionBuilder::new();
213        let mut obj_b = DictSectionBuilder::new();
214        let mut pred_b = DictSectionBuilder::new();
215
216        // Subjects ascending: shared (also an object) vs subject-only. Promoted
217        // objects are merged into the shared run in term order.
218        let mut promoted_iter = promoted.iter().peekable();
219        for s in &subjects {
220            if object_set.contains(s) {
221                while let Some(p) = promoted_iter.next_if(|p| p.as_str() < s.as_str()) {
222                    shared_b.push(p.clone());
223                }
224                shared_b.push(s.clone());
225            } else {
226                subj_b.push(s.clone());
227            }
228        }
229        for p in promoted_iter {
230            shared_b.push(p.clone());
231        }
232        // Objects ascending: object-only (shared terms were emitted above),
233        // with their counts when the caller asked for them.
234        // RDF-star: a quoted triple only ever appears in subject/object position,
235        // so a single prefix scan of those term sets detects the whole file.
236        let mut has_quoted_triples = subjects.iter().any(|s| s.starts_with("<<"));
237        let mut object_only_counts: Vec<u64> = Vec::new();
238        let mut take_object = |o: &String, n: u64| {
239            has_quoted_triples |= o.starts_with("<<");
240            if !subject_set.contains(o.as_str()) && !promoted_set.contains(o.as_str()) {
241                obj_b.push(o.clone());
242                if count_objects {
243                    object_only_counts.push(n);
244                }
245            }
246        };
247        match object_set {
248            ObjectTerms::Counted(counts) if count_objects => {
249                let mut objects: Vec<(String, u64)> = counts.into_iter().collect();
250                sort_counted_terms(&mut objects);
251                for (o, n) in &objects {
252                    take_object(o, *n);
253                }
254            }
255            object_set => {
256                let mut objects: Vec<String> = object_set.into_terms();
257                sort_terms(&mut objects);
258                for o in &objects {
259                    take_object(o, 0);
260                }
261            }
262        }
263        for p in &predicates {
264            pred_b.push(p.clone());
265        }
266
267        let shared = shared_b.build();
268        let mut dict =
269            Dictionary::from_sections([shared, subj_b.build(), obj_b.build(), pred_b.build()]);
270        dict.has_quoted_triples = has_quoted_triples;
271        (dict, object_only_counts)
272    }
273}
274
275/// Sort `(term, count)` pairs by term — the terms are distinct, so the
276/// count never decides. In parallel when the `parallel` feature is on.
277fn sort_counted_terms(v: &mut [(String, u64)]) {
278    #[cfg(feature = "parallel")]
279    {
280        use rayon::slice::ParallelSliceMut;
281        v.par_sort_unstable_by(|a, b| a.0.cmp(&b.0));
282    }
283    #[cfg(not(feature = "parallel"))]
284    v.sort_unstable_by(|a, b| a.0.cmp(&b.0));
285}
286
287/// The object terms a [`DictionaryBuilder`] observed: a set, or the
288/// occurrence counts frequent-object promotion needs.
289enum ObjectTerms {
290    Plain(HashSet<String>),
291    Counted(HashMap<String, u64>),
292}
293
294impl ObjectTerms {
295    fn contains(&self, term: &str) -> bool {
296        match self {
297            ObjectTerms::Plain(s) => s.contains(term),
298            ObjectTerms::Counted(m) => m.contains_key(term),
299        }
300    }
301
302    fn into_terms(self) -> Vec<String> {
303        match self {
304            ObjectTerms::Plain(s) => s.into_iter().collect(),
305            ObjectTerms::Counted(m) => m.into_keys().collect(),
306        }
307    }
308}
309
310/// Reusable per-window scratch for [`Dictionary::resolve_window`]: one job list
311/// per section (`(local_id, out_slot)` pairs) and the resolved-term output.
312/// Held across windows by the export loop so a window allocates only when a
313/// buffer must grow.
314#[derive(Default)]
315pub struct WindowResolver {
316    /// Jobs per dictionary section (shared, subject-only, object-only,
317    /// predicates): each `(section-local id, output slot)`.
318    jobs: [Vec<(u32, usize)>; 4],
319    /// Resolved terms, indexed by output slot `row * 3 + {0:s, 1:p, 2:o}`.
320    out: Vec<Option<String>>,
321}
322
323/// The most chunks one id-list prefetch ([`Dictionary::prefetch_subject_terms`],
324/// [`Dictionary::prefetch_node_terms`]) faults in a single batch. A batch holds
325/// every body it fetched until all are cached, so this bounds that transient
326/// (512 chunks of ≤64 KiB: ≤32 MiB) however many ids a caller passes; a longer
327/// list goes out in several batches.
328const PREFETCH_BATCH_CHUNKS: usize = 512;
329
330/// A read-only dictionary mapping terms ↔ role-specific IDs.
331///
332/// Holds the four sections as [`ChunkedSection`]s — local files keep each
333/// section as one resident chunk; a lazily-opened remote file faults
334/// individual chunks in on first touch. Lookups never re-parse a header.
335pub struct Dictionary {
336    /// shared, subjects, objects, predicates.
337    sections: [ChunkedSection; 4],
338    shared_len: u32,
339    /// Whether any term is an RDF-star quoted triple (`<< … >>`). Set at build
340    /// time (from the observed terms); `false` on the read path, where the
341    /// header's `FLAG_HAS_QUOTED_TRIPLES` is the source of truth instead.
342    has_quoted_triples: bool,
343    /// Faults the chunks of several sections in one batch (a ranged open);
344    /// `None` = section by section (local, or no batch reader).
345    multi_bulk: Option<MultiChunkBulkLoader>,
346    /// The literal object ids ([`Self::literal_objects`]), once found.
347    literal_objects: std::sync::OnceLock<LiteralObjects>,
348}
349
350/// Which object ids are literals. Literal tokens (`"…`) sort before IRIs
351/// (`<…`), blank nodes (`_:…`) and quoted triples (`<<…`) in every dictionary
352/// section, so the literals of a section are one run of consecutive ids: the
353/// shared section's are normally none (a literal is never a subject), the
354/// object-only section's come first.
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub struct LiteralObjects {
357    /// Shared object ids `shared.0+1..=shared.1` are literals.
358    pub shared: (u32, u32),
359    /// `S`, the shared-term count: object ids above it are object-only.
360    pub n_shared: u32,
361    /// Object-only object ids `S+only.0+1..=S+only.1` are literals.
362    pub only: (u32, u32),
363}
364
365impl LiteralObjects {
366    /// Is every object id in `lo..=hi` a literal?
367    pub fn covers(&self, lo: u32, hi: u32) -> bool {
368        let within = |(a, b): (u64, u64)| lo as u64 > a && hi as u64 <= b;
369        lo <= hi
370            && (within((self.shared.0 as u64, self.shared.1 as u64))
371                || within((
372                    self.n_shared as u64 + self.only.0 as u64,
373                    self.n_shared as u64 + self.only.1 as u64,
374                )))
375    }
376
377    /// Is object id `o` a literal?
378    pub fn contains(&self, o: u32) -> bool {
379        self.covers(o, o)
380    }
381}
382
383impl Dictionary {
384    /// Rebuild from four serialized sections (shared, subjects, objects,
385    /// predicates), e.g. when reading a `.rete` file. The four sections share one
386    /// [`ChunkCache`] (unlimited cap here, so every section body stays resident
387    /// exactly as before).
388    pub fn from_sections(sections: [Vec<u8>; 4]) -> Self {
389        let cache = ChunkCache::unlimited_arc();
390        let mut section_index = 0u8;
391        let chunked = sections.map(|bytes| {
392            let s = ChunkedSection::local(bytes, cache.clone(), section_index);
393            section_index += 1;
394            s
395        });
396        Self::from_chunked_sections(chunked)
397    }
398
399    /// Rebuild from four already-chunked sections (the remote lazy-open path).
400    pub fn from_chunked_sections(sections: [ChunkedSection; 4]) -> Self {
401        let shared_len = sections[0].term_count();
402        Dictionary {
403            sections,
404            shared_len,
405            has_quoted_triples: false,
406            multi_bulk: None,
407            literal_objects: std::sync::OnceLock::new(),
408        }
409    }
410
411    /// The object ids that are literals (see [`LiteralObjects`]): two binary
412    /// searches per section for where the `"` tokens start and end, each in the
413    /// one chunk the boundary routes to — two chunk faults at most on a lazy
414    /// open, done once. `None` when a chunk cannot be read, which a caller
415    /// treats as "not proven".
416    pub fn literal_objects(&self) -> Option<LiteralObjects> {
417        if let Some(found) = self.literal_objects.get() {
418            return Some(*found);
419        }
420        // `"` is 0x22 and `#` the next byte: a literal token sorts in
421        // [`"`, `#`), and nothing a well-formed file holds sorts before it.
422        // A failed read is not cached, so the next query tries again.
423        let run = |si: usize| -> Option<(u32, u32)> {
424            Some((self.sections[si].rank("\"")?, self.sections[si].rank("#")?))
425        };
426        let found = LiteralObjects {
427            shared: run(0)?,
428            n_shared: self.shared_len,
429            only: run(2)?,
430        };
431        Some(*self.literal_objects.get_or_init(|| found))
432    }
433
434    /// Attach a cross-section chunk fetcher (see [`MultiChunkBulkLoader`]):
435    /// the term prefetchers then fault every section's missing chunks in one
436    /// batch instead of one batch per section.
437    pub(crate) fn with_multi_bulk_loader(mut self, loader: MultiChunkBulkLoader) -> Self {
438        self.multi_bulk = Some(loader);
439        self
440    }
441
442    /// Fault the chunks `want[si]` of each section, at most `batch` chunks
443    /// per read. With a cross-section fetcher every missing chunk goes out in
444    /// one batch (round trips: one, not one per section); without one, each
445    /// section batches its own. A failed batch leaves the chunks for the
446    /// per-chunk loader, which retries them lookup by lookup. Purely a cache
447    /// warmer: results never depend on it.
448    fn prefetch_sections(&self, want: &[Vec<usize>; 4], batch: usize) {
449        let batch = batch.max(2);
450        if let Some(multi) = &self.multi_bulk {
451            let pairs: Vec<(u8, usize)> = want
452                .iter()
453                .enumerate()
454                .flat_map(|(si, cis)| {
455                    self.sections[si]
456                        .missing_chunks(cis)
457                        .into_iter()
458                        .map(move |ci| (si as u8, ci))
459                })
460                .collect();
461            // A single missing chunk costs the same either way: leave it to
462            // the per-chunk loader, as a section's own batch does.
463            if pairs.len() < 2 {
464                return;
465            }
466            for part in pairs.chunks(batch) {
467                if let Some(bodies) = multi(part) {
468                    if bodies.len() == part.len() {
469                        for (&(si, ci), body) in part.iter().zip(bodies) {
470                            self.sections[si as usize].seed_chunk(ci, body);
471                        }
472                    }
473                }
474            }
475            return;
476        }
477        for (si, cis) in want.iter().enumerate() {
478            for part in cis.chunks(batch) {
479                self.sections[si].prefetch_chunks(part);
480            }
481        }
482    }
483
484    /// Total number of distinct terms across all four sections.
485    pub fn term_count(&self) -> u32 {
486        self.sections.iter().map(|s| s.term_count()).sum()
487    }
488
489    /// Set the byte cap of the shared chunk cache (bounded-export phase 2).
490    /// `u64::MAX` (the default from every open) means unlimited — no eviction,
491    /// residency identical to the pre-budget design. A finite cap evicts
492    /// least-recently-used chunk bodies down to it on every fault, so a full
493    /// dump's peak dictionary residency is bounded rather than the whole
494    /// decompressed dictionary. All four sections share one cache; setting it on
495    /// each is idempotent (they are the same `Arc`).
496    pub fn set_cache_cap(&self, cap: u64) {
497        for s in &self.sections {
498            s.cache().set_cap(cap);
499        }
500    }
501
502    /// Heap bytes the four sections' chunk directories hold for as long as
503    /// the dictionary is open (see [`ChunkedSection::directory_bytes`]).
504    ///
505    /// [`ChunkedSection::directory_bytes`]: crate::dict::ChunkedSection::directory_bytes
506    pub fn directory_bytes(&self) -> u64 {
507        self.sections.iter().map(|s| s.directory_bytes()).sum()
508    }
509
510    /// The current chunk-cache byte cap (`u64::MAX` = unlimited).
511    pub fn cache_cap(&self) -> u64 {
512        self.sections[0].cache().cap()
513    }
514
515    /// A snapshot of the shared chunk cache's observability counters — for
516    /// `RETE_OPEN_DEBUG` accounting and the eviction tests.
517    pub fn cache_stats(&self) -> crate::chunk_cache::CacheStats {
518        self.sections[0].cache().stats()
519    }
520
521    /// Does this dataset contain RDF-star quoted triples? Meaningful only for a
522    /// freshly built dictionary (a read-back one carries the truth in the file
523    /// header's `FLAG_HAS_QUOTED_TRIPLES`, not here).
524    pub fn has_quoted_triples(&self) -> bool {
525        self.has_quoted_triples
526    }
527
528    /// Did any lazy chunk fetch fail since this dictionary was opened — or
529    /// since the last [`reset_load_failure`](Self::reset_load_failure)?
530    pub fn load_incomplete(&self) -> bool {
531        self.sections.iter().any(|s| s.load_incomplete())
532    }
533
534    /// Forget recorded fetch failures across every section (see
535    /// `GraphIndex::reset_load_failure` — the per-query reset for resident
536    /// sessions). Failed chunks were never cached, so they simply retry.
537    pub fn reset_load_failure(&self) {
538        self.sections.iter().for_each(|s| s.reset_load_failure());
539    }
540
541    /// Batch-fault every unloaded chunk of every section (no-op for local
542    /// dictionaries). Callers about to resolve *every* term — export, dump —
543    /// call this once so the sweep coalesces into a few range reads instead
544    /// of one fetch per chunk.
545    pub fn prefetch_all(&self) {
546        for s in &self.sections {
547            s.prefetch_all();
548        }
549    }
550
551    /// Batch-fault just the chunks needed to resolve a bounded result set:
552    /// `node_ids` are unified-node ids (the `Val::Id(id)` with `id >= 0`),
553    /// `predicate_ids` are predicate-space ids. Groups them by `(section,
554    /// chunk)` and coalesces each section's faults into a few range reads —
555    /// turning "one request per distinct output term" into "a handful per
556    /// query". No-op for a local dictionary (every chunk is already resident).
557    pub fn prefetch_terms(&self, node_ids: &[u32], predicate_ids: &[u32]) {
558        self.prefetch_terms_batched(node_ids, predicate_ids, usize::MAX);
559    }
560
561    /// [`prefetch_terms`](Self::prefetch_terms), faulting at most `batch` chunks
562    /// per bulk read. A bulk read holds every chunk it fetches — the coalesced
563    /// span, the per-chunk slices and the decoded bodies — until all of them
564    /// are cached, so an unbatched prefetch of a scattered window materialized
565    /// up to thousands of chunks at once, outside every cache cap. A bounded
566    /// export batches it so that transient stays a few MB.
567    pub fn prefetch_terms_batched(&self, node_ids: &[u32], predicate_ids: &[u32], batch: usize) {
568        let mut want: [std::collections::BTreeSet<usize>; 4] = Default::default();
569        for &n in node_ids {
570            if let Some((si, ci)) = self.node_chunk(n) {
571                want[si].insert(ci);
572            }
573        }
574        for &p in predicate_ids {
575            if let Some(ci) = self.sections[3].chunk_of_id(p) {
576                want[3].insert(ci);
577            }
578        }
579        let want: [Vec<usize>; 4] = want.map(|set| set.into_iter().collect());
580        self.prefetch_sections(&want, batch);
581    }
582
583    /// Does any section fault chunk bodies in on demand (a lazy or ranged
584    /// open)? `false` for a resident dictionary, where every prefetch is a no-op.
585    pub fn faults_chunks(&self) -> bool {
586        self.sections.iter().any(|s| s.faults_chunks())
587    }
588
589    /// Batch-fault every chunk that looking these terms up will read, so the
590    /// lookups that follow are cache hits: **one** batched read (one round trip
591    /// remotely) instead of one dependent fetch per term and section.
592    ///
593    /// `node_terms` are looked up as nodes ([`node_of_term`](Self::node_of_term),
594    /// [`subject_id`](Self::subject_id), [`object_id`](Self::object_id)), which
595    /// probe the shared section and then the subject-only or object-only one —
596    /// so all three routed chunks are fetched, plus those of the other spelling
597    /// of a string literal (`"x"` ↔ `"x"^^xsd:string`). `pred_terms` are looked
598    /// up in the predicate section. The routing is the lookup's own
599    /// ([`ChunkedSection::chunk_for_term`]), read off the resident chunk
600    /// directories. Purely a cache warmer: no-op on a resident dictionary, and
601    /// a failed batch leaves every chunk to the per-lookup loader.
602    ///
603    /// [`ChunkedSection::chunk_for_term`]: crate::dict::ChunkedSection::chunk_for_term
604    pub fn prefetch_lookups<'a>(
605        &self,
606        node_terms: impl IntoIterator<Item = &'a str>,
607        pred_terms: impl IntoIterator<Item = &'a str>,
608    ) {
609        if !self.faults_chunks() {
610            return;
611        }
612        let mut want: [std::collections::BTreeSet<usize>; 4] = Default::default();
613        for term in node_terms {
614            let alias = crate::terms::string_literal_alias(term);
615            for t in std::iter::once(term).chain(alias.as_deref()) {
616                for (si, set) in want.iter_mut().enumerate().take(3) {
617                    if let Some(ci) = self.sections[si].chunk_for_term(t) {
618                        set.insert(ci);
619                    }
620                }
621            }
622        }
623        for term in pred_terms {
624            if let Some(ci) = self.sections[3].chunk_for_term(term) {
625                want[3].insert(ci);
626            }
627        }
628        let want: [Vec<usize>; 4] = want.map(|set| set.into_iter().collect());
629        self.prefetch_sections(&want, PREFETCH_BATCH_CHUNKS);
630    }
631
632    /// The `(section, chunk)` holding a unified node id — mirrors the section
633    /// routing in [`node_term`](Self::node_term).
634    fn node_chunk(&self, node: u32) -> Option<(usize, usize)> {
635        let su = self.subject_only_count();
636        let (si, local) = if node < self.shared_len + su {
637            let id = node + 1; // subject_term(id)
638            if id <= self.shared_len {
639                (0, id)
640            } else {
641                (1, id - self.shared_len)
642            }
643        } else {
644            let id = node + 1 - su; // object_term(id)
645            if id <= self.shared_len {
646                (0, id)
647            } else {
648                (2, id - self.shared_len)
649            }
650        };
651        Some((si, self.sections[si].chunk_of_id(local)?))
652    }
653
654    /// Number of shared terms `S`.
655    pub fn shared_count(&self) -> u32 {
656        self.shared_len
657    }
658
659    /// Number of subject-only terms `Su`.
660    pub fn subject_only_count(&self) -> u32 {
661        self.sections[1].term_count()
662    }
663
664    /// Number of object-only terms `Oo`.
665    pub fn object_only_count(&self) -> u32 {
666        self.sections[2].term_count()
667    }
668
669    /// Number of predicate terms.
670    pub fn predicate_count(&self) -> u32 {
671        self.sections[3].term_count()
672    }
673
674    /// [`subject_term`](Self::subject_term) for many ids at once: one
675    /// chunk-ordered, single-pass walk per section
676    /// ([`ChunkedSection::resolve_into`]) instead of a fault and a run walk per
677    /// id — the difference between a whole-dictionary pass taking minutes and
678    /// taking hours. `out[i]` is exactly what `subject_term(ids[i])` returns.
679    ///
680    /// [`ChunkedSection::resolve_into`]: crate::dict::ChunkedSection::resolve_into
681    pub fn subject_terms(&self, ids: &[SubjectId]) -> Vec<Option<String>> {
682        self.role_terms(ids, 1)
683    }
684
685    /// [`object_term`](Self::object_term) for many ids at once — see
686    /// [`subject_terms`](Self::subject_terms).
687    pub fn object_terms(&self, ids: &[ObjectId]) -> Vec<Option<String>> {
688        self.role_terms(ids, 2)
689    }
690
691    /// Shared ids route to section 0, the rest to `own` (1 subjects, 2 objects)
692    /// with the shared block subtracted — the routing of `subject_term` /
693    /// `object_term`.
694    fn role_terms(&self, ids: &[u32], own: usize) -> Vec<Option<String>> {
695        let mut out = vec![None; ids.len()];
696        let mut shared = Vec::new();
697        let mut rest = Vec::new();
698        for (slot, &id) in ids.iter().enumerate() {
699            if id <= self.shared_len {
700                shared.push((id, slot));
701            } else {
702                rest.push((id - self.shared_len, slot));
703            }
704        }
705        self.sections[0].resolve_into(&mut shared, &mut out);
706        self.sections[own].resolve_into(&mut rest, &mut out);
707        out
708    }
709
710    // --- unified node space (for graph algorithms / the pyramid) ----------
711    //
712    // Subjects and objects use *separate* ID spaces that overlap on shared
713    // terms; the pyramid needs one node per distinct term. We lay them out as:
714    //   [0 .. S)            shared
715    //   [S .. S+Su)         subject-only
716    //   [S+Su .. S+Su+Oo)   object-only
717
718    /// Total distinct graph nodes `N = S + Su + Oo`.
719    pub fn node_count(&self) -> u32 {
720        self.shared_len + self.subject_only_count() + self.object_only_count()
721    }
722
723    /// Unified node ID for a subject-role ID. Shared and subject-only IDs are
724    /// already contiguous (`1..=S+Su`), so this is just `sid - 1`.
725    pub fn subject_node(&self, sid: SubjectId) -> NodeId {
726        // saturating_sub: a corrupt block may carry id 0; valid ids are ≥1 so this
727        // is identical for well-formed files but never underflow-panics.
728        sid.saturating_sub(1)
729    }
730
731    /// Unified node ID for an object-role ID: shared stay at `oid-1`, object-only
732    /// IDs are shifted past the subject-only block.
733    pub fn object_node(&self, oid: ObjectId) -> NodeId {
734        if oid <= self.shared_len {
735            oid.saturating_sub(1)
736        } else {
737            oid.saturating_sub(1) + self.subject_only_count()
738        }
739    }
740
741    /// Resolve a unified node ID back to its term.
742    pub fn node_term(&self, node: NodeId) -> Option<String> {
743        let su = self.subject_only_count();
744        if node < self.shared_len + su {
745            self.subject_term(node + 1)
746        } else {
747            self.object_term(node + 1 - su)
748        }
749    }
750
751    /// Batch-fault the dictionary chunks that decoding these subject-role ids
752    /// will touch — one coalesced read per section instead of a blocking round
753    /// trip per id. Purely a cache warmer (resident sections no-op).
754    pub fn prefetch_subject_terms(&self, ids: &[SubjectId]) {
755        let mut per_section: [Vec<usize>; 2] = Default::default();
756        for &id in ids {
757            let (si, local) = if id <= self.shared_len {
758                (0usize, id)
759            } else {
760                (1, id - self.shared_len)
761            };
762            if let Some(ci) = self.sections[si].chunk_of_term(local) {
763                per_section[si].push(ci);
764            }
765        }
766        let [shared, subjects] = per_section;
767        self.prefetch_sorted([shared, subjects, Vec::new(), Vec::new()]);
768    }
769
770    /// Sort, dedup and fault each section's chunk list (one batch across
771    /// sections when the dictionary has a cross-section fetcher), at most
772    /// [`PREFETCH_BATCH_CHUNKS`] chunks per read.
773    fn prefetch_sorted(&self, mut want: [Vec<usize>; 4]) {
774        for cis in &mut want {
775            cis.sort_unstable();
776            cis.dedup();
777        }
778        self.prefetch_sections(&want, PREFETCH_BATCH_CHUNKS);
779    }
780
781    /// [`prefetch_subject_terms`](Self::prefetch_subject_terms) for unified
782    /// node ids — the id space solution rows carry. Warms every chunk a batch
783    /// of row decodes (a FILTER's string ops, the projection) is about to
784    /// fault, using [`node_term`](Self::node_term)'s exact routing.
785    pub fn prefetch_node_terms(&self, nodes: &[NodeId]) {
786        let su = self.subject_only_count();
787        let mut per_section: [Vec<usize>; 3] = Default::default();
788        for &node in nodes {
789            let (si, local) = if node < self.shared_len + su {
790                let sid = node + 1;
791                if sid <= self.shared_len {
792                    (0usize, sid)
793                } else {
794                    (1, sid - self.shared_len)
795                }
796            } else {
797                let oid = node + 1 - su;
798                if oid <= self.shared_len {
799                    (0, oid)
800                } else {
801                    (2, oid - self.shared_len)
802                }
803            };
804            if let Some(ci) = self.sections[si].chunk_of_term(local) {
805                per_section[si].push(ci);
806            }
807        }
808        let [shared, subjects, objects] = per_section;
809        self.prefetch_sorted([shared, subjects, objects, Vec::new()]);
810    }
811
812    /// Unified node ID for a term, resolving via subject role then object role.
813    /// A string literal also matches its other spelling (`"x"` ↔
814    /// `"x"^^xsd:string`, one RDF 1.1 term; see [`Self::subject_id`]).
815    pub fn node_of_term(&self, term: &str) -> Option<NodeId> {
816        let exact = |t: &str| {
817            self.subject_id_exact(t)
818                .map(|sid| self.subject_node(sid))
819                .or_else(|| self.object_id_exact(t).map(|oid| self.object_node(oid)))
820        };
821        exact(term).or_else(|| exact(&crate::terms::string_literal_alias(term)?))
822    }
823
824    /// Subject-role ID of a node, or `None` if the node never appears as a
825    /// subject (an object-only term).
826    pub fn node_as_subject_id(&self, node: NodeId) -> Option<SubjectId> {
827        if node < self.shared_len + self.subject_only_count() {
828            Some(node + 1)
829        } else {
830            None
831        }
832    }
833
834    /// Object-role ID of a node, or `None` if the node never appears as an
835    /// object (a subject-only term).
836    pub fn node_as_object_id(&self, node: NodeId) -> Option<ObjectId> {
837        let (s, su) = (self.shared_len, self.subject_only_count());
838        if node < s {
839            Some(node + 1) // shared
840        } else if node >= s + su {
841            Some(node + 1 - su) // object-only
842        } else {
843            None // subject-only
844        }
845    }
846
847    // --- term -> id -------------------------------------------------------
848
849    /// Subject-role ID for `term`: shared `1..=S`, else subject-only `S+1..`.
850    ///
851    /// A string literal that is not found under its own spelling is looked up
852    /// under the other (`"x"` ↔ `"x"^^xsd:string`): RDF 1.1 makes them one
853    /// term, ingest keeps only the plain one, and a file built before it did
854    /// may hold the typed one. The second lookup runs only on a miss, and the
855    /// two spellings sort side by side, so it reads no further chunk.
856    pub fn subject_id(&self, term: &str) -> Option<SubjectId> {
857        self.subject_id_exact(term)
858            .or_else(|| self.subject_id_exact(&crate::terms::string_literal_alias(term)?))
859    }
860
861    /// Object-role ID for `term`, with the same string-literal fallback as
862    /// [`Self::subject_id`].
863    pub fn object_id(&self, term: &str) -> Option<ObjectId> {
864        self.object_id_exact(term)
865            .or_else(|| self.object_id_exact(&crate::terms::string_literal_alias(term)?))
866    }
867
868    fn subject_id_exact(&self, term: &str) -> Option<SubjectId> {
869        if let Some(id) = self.sections[0].id(term) {
870            return Some(id);
871        }
872        self.sections[1].id(term).map(|id| self.shared_len + id)
873    }
874
875    /// Object-role ID for `term`: shared `1..=S`, else object-only `S+1..`.
876    fn object_id_exact(&self, term: &str) -> Option<ObjectId> {
877        if let Some(id) = self.sections[0].id(term) {
878            return Some(id);
879        }
880        self.sections[2].id(term).map(|id| self.shared_len + id)
881    }
882
883    /// Predicate ID for `term` (independent space).
884    pub fn predicate_id(&self, term: &str) -> Option<PredicateId> {
885        self.sections[3].id(term)
886    }
887
888    // --- id -> term -------------------------------------------------------
889
890    pub fn subject_term(&self, id: SubjectId) -> Option<String> {
891        if id <= self.shared_len {
892            self.sections[0].term(id)
893        } else {
894            self.sections[1].term(id - self.shared_len)
895        }
896    }
897
898    pub fn object_term(&self, id: ObjectId) -> Option<String> {
899        if id <= self.shared_len {
900            self.sections[0].term(id)
901        } else {
902            self.sections[2].term(id - self.shared_len)
903        }
904    }
905
906    pub fn predicate_term(&self, id: PredicateId) -> Option<String> {
907        self.sections[3].term(id)
908    }
909
910    /// Encode a `(s, p, o)` term triple to its `(subject_id, predicate_id,
911    /// object_id)`. `None` if any term is unknown.
912    pub fn encode(&self, s: &str, p: &str, o: &str) -> Option<(SubjectId, PredicateId, ObjectId)> {
913        Some((
914            self.subject_id(s)?,
915            self.predicate_id(p)?,
916            self.object_id(o)?,
917        ))
918    }
919
920    /// Resolve a window of `(subject_id, predicate_id, object_id)` triples in
921    /// **chunk order** and emit each fully-resolved row through `emit` in the
922    /// window's **original order**.
923    ///
924    /// This is the batch twin of resolving each row with
925    /// [`subject_term`](Self::subject_term) /
926    /// [`predicate_term`](Self::predicate_term) /
927    /// [`object_term`](Self::object_term) and emitting on `(Some, Some, Some)`
928    /// — the exact thing it replaces inside `Rete::dump_filtered_each`. A row is
929    /// emitted only when all three of its terms resolve, and the bytes of each
930    /// term are identical to the per-row path, so the dump stays
931    /// byte-for-byte the same.
932    ///
933    /// What changes is *how* the terms are decoded. Each row contributes three
934    /// jobs to the section that owns its id (shared/subject-only for the
935    /// subject, predicates for the predicate, shared/object-only for the
936    /// object); each section then resolves its jobs in one chunk-ordered,
937    /// single-pass walk ([`ChunkedSection::resolve_into`]). A window whose
938    /// object ids scatter across the object-only section therefore touches each
939    /// chunk once — one faulted body, one forward walk — instead of the
940    /// scattered re-faults and per-row re-walks the row-order path performed.
941    ///
942    /// `w` carries the per-window scratch buffers so back-to-back windows cost
943    /// no allocation beyond growth.
944    ///
945    /// [`ChunkedSection::resolve_into`]: crate::dict::ChunkedSection::resolve_into
946    pub fn resolve_window(
947        &self,
948        ids: &[(SubjectId, PredicateId, ObjectId)],
949        w: &mut WindowResolver,
950        mut emit: impl FnMut(&str, &str, &str),
951    ) {
952        for j in &mut w.jobs {
953            j.clear();
954        }
955        w.out.clear();
956        w.out.resize(ids.len() * 3, None);
957        for (i, &(s, p, o)) in ids.iter().enumerate() {
958            // Subject role: shared (section 0) if `id <= S`, else subject-only
959            // (section 1) with the shared block subtracted — the exact routing
960            // of `subject_term`.
961            if s <= self.shared_len {
962                w.jobs[0].push((s, i * 3));
963            } else {
964                w.jobs[1].push((s - self.shared_len, i * 3));
965            }
966            // Predicate role: its own section (3).
967            w.jobs[3].push((p, i * 3 + 1));
968            // Object role: shared (section 0) if `id <= S`, else object-only
969            // (section 2) — the exact routing of `object_term`.
970            if o <= self.shared_len {
971                w.jobs[0].push((o, i * 3 + 2));
972            } else {
973                w.jobs[2].push((o - self.shared_len, i * 3 + 2));
974            }
975        }
976        for si in 0..4 {
977            self.sections[si].resolve_into(&mut w.jobs[si], &mut w.out);
978        }
979        for i in 0..ids.len() {
980            if let (Some(s), Some(p), Some(o)) =
981                (&w.out[i * 3], &w.out[i * 3 + 1], &w.out[i * 3 + 2])
982            {
983                emit(s, p, o);
984            }
985        }
986    }
987
988    /// The four serialized sections (header + body each), for the file writer.
989    pub fn sections(&self) -> [Vec<u8>; 4] {
990        [
991            self.sections[0].raw_section_bytes(),
992            self.sections[1].raw_section_bytes(),
993            self.sections[2].raw_section_bytes(),
994            self.sections[3].raw_section_bytes(),
995        ]
996    }
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002
1003    fn dict() -> Dictionary {
1004        let mut b = DictionaryBuilder::new();
1005        // Alice knows Bob; Bob knows Carol. So Bob is both subject and object
1006        // (shared); Alice is subject-only; Carol is object-only.
1007        b.observe("Alice", "knows", "Bob");
1008        b.observe("Bob", "knows", "Carol");
1009        b.build()
1010    }
1011
1012    #[test]
1013    fn shared_term_has_same_id_in_both_roles() {
1014        let d = dict();
1015        assert_eq!(d.shared_count(), 1); // just "Bob"
1016        let sid = d.subject_id("Bob").unwrap();
1017        let oid = d.object_id("Bob").unwrap();
1018        assert_eq!(sid, oid, "shared term must share its ID across roles");
1019        assert_eq!(sid, 1, "shared terms get the lowest IDs");
1020    }
1021
1022    #[test]
1023    fn role_specific_terms_round_trip() {
1024        let d = dict();
1025        // subject-only Alice
1026        let a = d.subject_id("Alice").unwrap();
1027        assert!(a > d.shared_count());
1028        assert_eq!(d.subject_term(a).as_deref(), Some("Alice"));
1029        // object-only Carol
1030        let c = d.object_id("Carol").unwrap();
1031        assert!(c > d.shared_count());
1032        assert_eq!(d.object_term(c).as_deref(), Some("Carol"));
1033        // predicate
1034        let k = d.predicate_id("knows").unwrap();
1035        assert_eq!(d.predicate_term(k).as_deref(), Some("knows"));
1036        // Alice never appears as object, Carol never as subject.
1037        assert_eq!(d.object_id("Alice"), None);
1038        assert_eq!(d.subject_id("Carol"), None);
1039    }
1040
1041    #[test]
1042    fn encode_full_triples() {
1043        let d = dict();
1044        let t1 = d.encode("Alice", "knows", "Bob").unwrap();
1045        let t2 = d.encode("Bob", "knows", "Carol").unwrap();
1046        // Bob is object in t1 and subject in t2 — same ID both times.
1047        assert_eq!(t1.2, t2.0);
1048        assert!(d.encode("Nobody", "knows", "Bob").is_none());
1049    }
1050}