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::HashSet;
11
12use crate::dict::{ChunkedSection, DictSectionBuilder};
13use crate::terms::{NodeId, ObjectId, PredicateId, SubjectId};
14
15/// Sort a term slice ascending — in parallel when the `parallel` feature is on.
16/// The front-coded dictionary sections need ascending order; doing it once here
17/// (rather than per-insert in a `BTreeSet`) is the fast path for large graphs.
18#[cfg(feature = "parallel")]
19fn sort_terms(v: &mut [String]) {
20    use rayon::slice::ParallelSliceMut;
21    v.par_sort_unstable();
22}
23#[cfg(not(feature = "parallel"))]
24fn sort_terms(v: &mut [String]) {
25    v.sort_unstable();
26}
27
28/// Builds a [`Dictionary`] from observed `(subject, predicate, object)` terms.
29///
30/// Terms are deduped with `HashSet` during ingest (O(1) average insert, no tree
31/// rebalancing) and sorted **once** in [`Self::build`]. This replaces a trio of
32/// `BTreeSet<String>` whose per-insert `O(log n)` cost + a `String` allocation
33/// for *every* observed term (most of them duplicates) dominated the build on
34/// large graphs.
35#[derive(Default)]
36pub struct DictionaryBuilder {
37    subjects: HashSet<String>,
38    objects: HashSet<String>,
39    predicates: HashSet<String>,
40}
41
42impl DictionaryBuilder {
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Record one triple's terms (IDs are assigned later, in [`Self::build`]).
48    /// We probe with the borrowed `&str` first and only allocate a `String` for
49    /// a genuinely new term — so a graph of N triples does far fewer than 3N
50    /// allocations (each subject/predicate recurs across many triples).
51    pub fn observe(&mut self, subject: &str, predicate: &str, object: &str) {
52        if !self.subjects.contains(subject) {
53            self.subjects.insert(subject.to_string());
54        }
55        if !self.objects.contains(object) {
56            self.objects.insert(object.to_string());
57        }
58        if !self.predicates.contains(predicate) {
59            self.predicates.insert(predicate.to_string());
60        }
61    }
62
63    pub fn build(self) -> Dictionary {
64        // Sort each role's terms once (ascending) — identical output order to
65        // the old BTreeSet iteration, but without the per-insert tree cost.
66        let mut subjects: Vec<String> = self.subjects.into_iter().collect();
67        let mut predicates: Vec<String> = self.predicates.into_iter().collect();
68        let object_set = self.objects; // kept for the shared (subject ∩ object) test
69        sort_terms(&mut subjects);
70        sort_terms(&mut predicates);
71
72        // A term is "shared" when it appears as both a subject and an object.
73        let subject_set: HashSet<&str> = subjects.iter().map(String::as_str).collect();
74
75        let mut shared_b = DictSectionBuilder::new();
76        let mut subj_b = DictSectionBuilder::new();
77        let mut obj_b = DictSectionBuilder::new();
78        let mut pred_b = DictSectionBuilder::new();
79
80        // Subjects ascending: shared (also an object) vs subject-only.
81        for s in &subjects {
82            if object_set.contains(s) {
83                shared_b.push(s.clone());
84            } else {
85                subj_b.push(s.clone());
86            }
87        }
88        // Objects ascending: object-only (shared terms were emitted above).
89        let mut objects: Vec<String> = object_set.into_iter().collect();
90        sort_terms(&mut objects);
91        for o in &objects {
92            if !subject_set.contains(o.as_str()) {
93                obj_b.push(o.clone());
94            }
95        }
96        for p in &predicates {
97            pred_b.push(p.clone());
98        }
99
100        // RDF-star: a quoted triple only ever appears in subject/object position,
101        // so a single prefix scan of those term sets detects the whole file.
102        let has_quoted_triples = subjects.iter().any(|s| s.starts_with("<<"))
103            || objects.iter().any(|o| o.starts_with("<<"));
104
105        let shared = shared_b.build();
106        let mut dict =
107            Dictionary::from_sections([shared, subj_b.build(), obj_b.build(), pred_b.build()]);
108        dict.has_quoted_triples = has_quoted_triples;
109        dict
110    }
111}
112
113/// A read-only dictionary mapping terms ↔ role-specific IDs.
114///
115/// Holds the four sections as [`ChunkedSection`]s — local files keep each
116/// section as one resident chunk; a lazily-opened remote file faults
117/// individual chunks in on first touch. Lookups never re-parse a header.
118pub struct Dictionary {
119    /// shared, subjects, objects, predicates.
120    sections: [ChunkedSection; 4],
121    shared_len: u32,
122    /// Whether any term is an RDF-star quoted triple (`<< … >>`). Set at build
123    /// time (from the observed terms); `false` on the read path, where the
124    /// header's `FLAG_HAS_QUOTED_TRIPLES` is the source of truth instead.
125    has_quoted_triples: bool,
126}
127
128impl Dictionary {
129    /// Rebuild from four serialized sections (shared, subjects, objects,
130    /// predicates), e.g. when reading a `.rete` file.
131    pub fn from_sections(sections: [Vec<u8>; 4]) -> Self {
132        Self::from_chunked_sections(sections.map(ChunkedSection::local))
133    }
134
135    /// Rebuild from four already-chunked sections (the remote lazy-open path).
136    pub fn from_chunked_sections(sections: [ChunkedSection; 4]) -> Self {
137        let shared_len = sections[0].term_count();
138        Dictionary {
139            sections,
140            shared_len,
141            has_quoted_triples: false,
142        }
143    }
144
145    /// Total number of distinct terms across all four sections.
146    pub fn term_count(&self) -> u32 {
147        self.sections.iter().map(|s| s.term_count()).sum()
148    }
149
150    /// Does this dataset contain RDF-star quoted triples? Meaningful only for a
151    /// freshly built dictionary (a read-back one carries the truth in the file
152    /// header's `FLAG_HAS_QUOTED_TRIPLES`, not here).
153    pub fn has_quoted_triples(&self) -> bool {
154        self.has_quoted_triples
155    }
156
157    /// Did any lazy chunk fetch fail since this dictionary was opened — or
158    /// since the last [`reset_load_failure`](Self::reset_load_failure)?
159    pub fn load_incomplete(&self) -> bool {
160        self.sections.iter().any(|s| s.load_incomplete())
161    }
162
163    /// Forget recorded fetch failures across every section (see
164    /// `GraphIndex::reset_load_failure` — the per-query reset for resident
165    /// sessions). Failed chunks were never cached, so they simply retry.
166    pub fn reset_load_failure(&self) {
167        self.sections.iter().for_each(|s| s.reset_load_failure());
168    }
169
170    /// Batch-fault every unloaded chunk of every section (no-op for local
171    /// dictionaries). Callers about to resolve *every* term — export, dump —
172    /// call this once so the sweep coalesces into a few range reads instead
173    /// of one fetch per chunk.
174    pub fn prefetch_all(&self) {
175        for s in &self.sections {
176            s.prefetch_all();
177        }
178    }
179
180    /// Batch-fault just the chunks needed to resolve a bounded result set:
181    /// `node_ids` are unified-node ids (the `Val::Id(id)` with `id >= 0`),
182    /// `predicate_ids` are predicate-space ids. Groups them by `(section,
183    /// chunk)` and coalesces each section's faults into a few range reads —
184    /// turning "one request per distinct output term" into "a handful per
185    /// query". No-op for a local dictionary (every chunk is already resident).
186    pub fn prefetch_terms(&self, node_ids: &[u32], predicate_ids: &[u32]) {
187        let mut want: [std::collections::BTreeSet<usize>; 4] = Default::default();
188        for &n in node_ids {
189            if let Some((si, ci)) = self.node_chunk(n) {
190                want[si].insert(ci);
191            }
192        }
193        for &p in predicate_ids {
194            if let Some(ci) = self.sections[3].chunk_of_id(p) {
195                want[3].insert(ci);
196            }
197        }
198        for (si, set) in want.iter().enumerate() {
199            if set.len() >= 2 {
200                let cis: Vec<usize> = set.iter().copied().collect();
201                self.sections[si].prefetch_chunks(&cis);
202            }
203        }
204    }
205
206    /// The `(section, chunk)` holding a unified node id — mirrors the section
207    /// routing in [`node_term`](Self::node_term).
208    fn node_chunk(&self, node: u32) -> Option<(usize, usize)> {
209        let su = self.subject_only_count();
210        let (si, local) = if node < self.shared_len + su {
211            let id = node + 1; // subject_term(id)
212            if id <= self.shared_len {
213                (0, id)
214            } else {
215                (1, id - self.shared_len)
216            }
217        } else {
218            let id = node + 1 - su; // object_term(id)
219            if id <= self.shared_len {
220                (0, id)
221            } else {
222                (2, id - self.shared_len)
223            }
224        };
225        Some((si, self.sections[si].chunk_of_id(local)?))
226    }
227
228    /// Number of shared terms `S`.
229    pub fn shared_count(&self) -> u32 {
230        self.shared_len
231    }
232
233    /// Number of subject-only terms `Su`.
234    pub fn subject_only_count(&self) -> u32 {
235        self.sections[1].term_count()
236    }
237
238    /// Number of object-only terms `Oo`.
239    pub fn object_only_count(&self) -> u32 {
240        self.sections[2].term_count()
241    }
242
243    // --- unified node space (for graph algorithms / the pyramid) ----------
244    //
245    // Subjects and objects use *separate* ID spaces that overlap on shared
246    // terms; the pyramid needs one node per distinct term. We lay them out as:
247    //   [0 .. S)            shared
248    //   [S .. S+Su)         subject-only
249    //   [S+Su .. S+Su+Oo)   object-only
250
251    /// Total distinct graph nodes `N = S + Su + Oo`.
252    pub fn node_count(&self) -> u32 {
253        self.shared_len + self.subject_only_count() + self.object_only_count()
254    }
255
256    /// Unified node ID for a subject-role ID. Shared and subject-only IDs are
257    /// already contiguous (`1..=S+Su`), so this is just `sid - 1`.
258    pub fn subject_node(&self, sid: SubjectId) -> NodeId {
259        // saturating_sub: a corrupt block may carry id 0; valid ids are ≥1 so this
260        // is identical for well-formed files but never underflow-panics.
261        sid.saturating_sub(1)
262    }
263
264    /// Unified node ID for an object-role ID: shared stay at `oid-1`, object-only
265    /// IDs are shifted past the subject-only block.
266    pub fn object_node(&self, oid: ObjectId) -> NodeId {
267        if oid <= self.shared_len {
268            oid.saturating_sub(1)
269        } else {
270            oid.saturating_sub(1) + self.subject_only_count()
271        }
272    }
273
274    /// Resolve a unified node ID back to its term.
275    pub fn node_term(&self, node: NodeId) -> Option<String> {
276        let su = self.subject_only_count();
277        if node < self.shared_len + su {
278            self.subject_term(node + 1)
279        } else {
280            self.object_term(node + 1 - su)
281        }
282    }
283
284    /// Batch-fault the dictionary chunks that decoding these subject-role ids
285    /// will touch — one coalesced read per section instead of a blocking round
286    /// trip per id. Purely a cache warmer (resident sections no-op).
287    pub fn prefetch_subject_terms(&self, ids: &[SubjectId]) {
288        let mut per_section: [Vec<usize>; 2] = Default::default();
289        for &id in ids {
290            let (si, local) = if id <= self.shared_len {
291                (0usize, id)
292            } else {
293                (1, id - self.shared_len)
294            };
295            if let Some(ci) = self.sections[si].chunk_of_term(local) {
296                per_section[si].push(ci);
297            }
298        }
299        for (si, cis) in per_section.iter_mut().enumerate() {
300            cis.sort_unstable();
301            cis.dedup();
302            self.sections[si].prefetch_chunks(cis);
303        }
304    }
305
306    /// [`prefetch_subject_terms`](Self::prefetch_subject_terms) for unified
307    /// node ids — the id space solution rows carry. Warms every chunk a batch
308    /// of row decodes (a FILTER's string ops, the projection) is about to
309    /// fault, using [`node_term`](Self::node_term)'s exact routing.
310    pub fn prefetch_node_terms(&self, nodes: &[NodeId]) {
311        let su = self.subject_only_count();
312        let mut per_section: [Vec<usize>; 3] = Default::default();
313        for &node in nodes {
314            let (si, local) = if node < self.shared_len + su {
315                let sid = node + 1;
316                if sid <= self.shared_len {
317                    (0usize, sid)
318                } else {
319                    (1, sid - self.shared_len)
320                }
321            } else {
322                let oid = node + 1 - su;
323                if oid <= self.shared_len {
324                    (0, oid)
325                } else {
326                    (2, oid - self.shared_len)
327                }
328            };
329            if let Some(ci) = self.sections[si].chunk_of_term(local) {
330                per_section[si].push(ci);
331            }
332        }
333        for (si, cis) in per_section.iter_mut().enumerate() {
334            cis.sort_unstable();
335            cis.dedup();
336            self.sections[si].prefetch_chunks(cis);
337        }
338    }
339
340    /// Unified node ID for a term, resolving via subject role then object role.
341    pub fn node_of_term(&self, term: &str) -> Option<NodeId> {
342        if let Some(sid) = self.subject_id(term) {
343            return Some(self.subject_node(sid));
344        }
345        self.object_id(term).map(|oid| self.object_node(oid))
346    }
347
348    /// Subject-role ID of a node, or `None` if the node never appears as a
349    /// subject (an object-only term).
350    pub fn node_as_subject_id(&self, node: NodeId) -> Option<SubjectId> {
351        if node < self.shared_len + self.subject_only_count() {
352            Some(node + 1)
353        } else {
354            None
355        }
356    }
357
358    /// Object-role ID of a node, or `None` if the node never appears as an
359    /// object (a subject-only term).
360    pub fn node_as_object_id(&self, node: NodeId) -> Option<ObjectId> {
361        let (s, su) = (self.shared_len, self.subject_only_count());
362        if node < s {
363            Some(node + 1) // shared
364        } else if node >= s + su {
365            Some(node + 1 - su) // object-only
366        } else {
367            None // subject-only
368        }
369    }
370
371    // --- term -> id -------------------------------------------------------
372
373    /// Subject-role ID for `term`: shared `1..=S`, else subject-only `S+1..`.
374    pub fn subject_id(&self, term: &str) -> Option<SubjectId> {
375        if let Some(id) = self.sections[0].id(term) {
376            return Some(id);
377        }
378        self.sections[1].id(term).map(|id| self.shared_len + id)
379    }
380
381    /// Object-role ID for `term`: shared `1..=S`, else object-only `S+1..`.
382    pub fn object_id(&self, term: &str) -> Option<ObjectId> {
383        if let Some(id) = self.sections[0].id(term) {
384            return Some(id);
385        }
386        self.sections[2].id(term).map(|id| self.shared_len + id)
387    }
388
389    /// Predicate ID for `term` (independent space).
390    pub fn predicate_id(&self, term: &str) -> Option<PredicateId> {
391        self.sections[3].id(term)
392    }
393
394    // --- id -> term -------------------------------------------------------
395
396    pub fn subject_term(&self, id: SubjectId) -> Option<String> {
397        if id <= self.shared_len {
398            self.sections[0].term(id)
399        } else {
400            self.sections[1].term(id - self.shared_len)
401        }
402    }
403
404    pub fn object_term(&self, id: ObjectId) -> Option<String> {
405        if id <= self.shared_len {
406            self.sections[0].term(id)
407        } else {
408            self.sections[2].term(id - self.shared_len)
409        }
410    }
411
412    pub fn predicate_term(&self, id: PredicateId) -> Option<String> {
413        self.sections[3].term(id)
414    }
415
416    /// Encode a `(s, p, o)` term triple to its `(subject_id, predicate_id,
417    /// object_id)`. `None` if any term is unknown.
418    pub fn encode(&self, s: &str, p: &str, o: &str) -> Option<(SubjectId, PredicateId, ObjectId)> {
419        Some((
420            self.subject_id(s)?,
421            self.predicate_id(p)?,
422            self.object_id(o)?,
423        ))
424    }
425
426    /// The four serialized sections (header + body each), for the file writer.
427    pub fn sections(&self) -> [Vec<u8>; 4] {
428        [
429            self.sections[0].raw_section_bytes(),
430            self.sections[1].raw_section_bytes(),
431            self.sections[2].raw_section_bytes(),
432            self.sections[3].raw_section_bytes(),
433        ]
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    fn dict() -> Dictionary {
442        let mut b = DictionaryBuilder::new();
443        // Alice knows Bob; Bob knows Carol. So Bob is both subject and object
444        // (shared); Alice is subject-only; Carol is object-only.
445        b.observe("Alice", "knows", "Bob");
446        b.observe("Bob", "knows", "Carol");
447        b.build()
448    }
449
450    #[test]
451    fn shared_term_has_same_id_in_both_roles() {
452        let d = dict();
453        assert_eq!(d.shared_count(), 1); // just "Bob"
454        let sid = d.subject_id("Bob").unwrap();
455        let oid = d.object_id("Bob").unwrap();
456        assert_eq!(sid, oid, "shared term must share its ID across roles");
457        assert_eq!(sid, 1, "shared terms get the lowest IDs");
458    }
459
460    #[test]
461    fn role_specific_terms_round_trip() {
462        let d = dict();
463        // subject-only Alice
464        let a = d.subject_id("Alice").unwrap();
465        assert!(a > d.shared_count());
466        assert_eq!(d.subject_term(a).as_deref(), Some("Alice"));
467        // object-only Carol
468        let c = d.object_id("Carol").unwrap();
469        assert!(c > d.shared_count());
470        assert_eq!(d.object_term(c).as_deref(), Some("Carol"));
471        // predicate
472        let k = d.predicate_id("knows").unwrap();
473        assert_eq!(d.predicate_term(k).as_deref(), Some("knows"));
474        // Alice never appears as object, Carol never as subject.
475        assert_eq!(d.object_id("Alice"), None);
476        assert_eq!(d.subject_id("Carol"), None);
477    }
478
479    #[test]
480    fn encode_full_triples() {
481        let d = dict();
482        let t1 = d.encode("Alice", "knows", "Bob").unwrap();
483        let t2 = d.encode("Bob", "knows", "Carol").unwrap();
484        // Bob is object in t1 and subject in t2 — same ID both times.
485        assert_eq!(t1.2, t2.0);
486        assert!(d.encode("Nobody", "knows", "Bob").is_none());
487    }
488}