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}
48
49impl DictionaryBuilder {
50    /// A builder with the process-wide writer settings
51    /// ([`crate::write_opts::frequent_objects`]).
52    pub fn new() -> Self {
53        Self::default().with_frequent_objects(crate::write_opts::frequent_objects())
54    }
55
56    /// Promote the `n` most frequent **object-only** terms (by the number of
57    /// statements that carry them as object; ties by term order) into the
58    /// shared section (`0` = off, the default).
59    ///
60    /// The shared section is numbered first and in term order, so a promoted
61    /// vocabulary term (a class, a scheme individual, a status literal) gets a
62    /// small object id wherever it sorts ahead of the shared entities — the
63    /// effect declaring the vocabulary in a TBox has, without the TBox. A
64    /// promoted term keeps its object id role; the subject id it also gets is
65    /// simply never used by any statement. Readers need nothing new: the shared
66    /// section has always been looked up first for both roles.
67    ///
68    /// Set it before the first [`Self::observe`]: promotion counts objects as
69    /// they are observed.
70    pub fn with_frequent_objects(mut self, n: usize) -> Self {
71        debug_assert!(
72            self.objects.is_empty() && self.object_counts.is_empty(),
73            "with_frequent_objects after observe"
74        );
75        self.promote = n;
76        self
77    }
78
79    /// Record one triple's terms (IDs are assigned later, in [`Self::build`]).
80    /// We probe with the borrowed `&str` first and only allocate a `String` for
81    /// a genuinely new term — so a graph of N triples does far fewer than 3N
82    /// allocations (each subject/predicate recurs across many triples).
83    pub fn observe(&mut self, subject: &str, predicate: &str, object: &str) {
84        if !self.subjects.contains(subject) {
85            self.subjects.insert(subject.to_string());
86        }
87        if self.promote > 0 {
88            if let Some(n) = self.object_counts.get_mut(object) {
89                *n += 1;
90            } else {
91                self.object_counts.insert(object.to_string(), 1);
92            }
93        } else if !self.objects.contains(object) {
94            self.objects.insert(object.to_string());
95        }
96        if !self.predicates.contains(predicate) {
97            self.predicates.insert(predicate.to_string());
98        }
99    }
100
101    /// Record a **graph name** as a term in the subject role — how the quad
102    /// index (SPEC.md §6.5) makes graph names dictionary ids. It lands in the
103    /// shared section when the name is also an object, else in the
104    /// subject-only section.
105    pub fn observe_graph(&mut self, graph: &str) {
106        if !self.subjects.contains(graph) {
107            self.subjects.insert(graph.to_string());
108        }
109    }
110
111    pub fn build(self) -> Dictionary {
112        // Sort each role's terms once (ascending) — identical output order to
113        // the old BTreeSet iteration, but without the per-insert tree cost.
114        let mut subjects: Vec<String> = self.subjects.into_iter().collect();
115        let mut predicates: Vec<String> = self.predicates.into_iter().collect();
116        sort_terms(&mut subjects);
117        sort_terms(&mut predicates);
118
119        // A term is "shared" when it appears as both a subject and an object.
120        let subject_set: HashSet<&str> = subjects.iter().map(String::as_str).collect();
121
122        // The object terms: a plain set, or (promotion on) the count map, used
123        // as-is rather than converted, so no second table over every object.
124        let object_set = if self.promote > 0 {
125            ObjectTerms::Counted(self.object_counts)
126        } else {
127            ObjectTerms::Plain(self.objects)
128        };
129        // Frequent-object promotion: the top `promote` object-only terms by
130        // statement count (ties by term order) join the shared section.
131        let mut promoted: Vec<String> = Vec::new();
132        if let ObjectTerms::Counted(counts) = &object_set {
133            let mut top: Vec<(&String, u64)> = counts
134                .iter()
135                .filter(|(t, _)| !subject_set.contains(t.as_str()))
136                .map(|(t, &n)| (t, n))
137                .collect();
138            let k = self.promote.min(top.len());
139            if k > 0 && k < top.len() {
140                top.select_nth_unstable_by(k - 1, |a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
141            }
142            top.truncate(k);
143            promoted = top.into_iter().map(|(t, _)| t.clone()).collect();
144            sort_terms(&mut promoted);
145        }
146        let promoted_set: HashSet<&str> = promoted.iter().map(String::as_str).collect();
147
148        let mut shared_b = DictSectionBuilder::new();
149        let mut subj_b = DictSectionBuilder::new();
150        let mut obj_b = DictSectionBuilder::new();
151        let mut pred_b = DictSectionBuilder::new();
152
153        // Subjects ascending: shared (also an object) vs subject-only. Promoted
154        // objects are merged into the shared run in term order.
155        let mut promoted_iter = promoted.iter().peekable();
156        for s in &subjects {
157            if object_set.contains(s) {
158                while let Some(p) = promoted_iter.next_if(|p| p.as_str() < s.as_str()) {
159                    shared_b.push(p.clone());
160                }
161                shared_b.push(s.clone());
162            } else {
163                subj_b.push(s.clone());
164            }
165        }
166        for p in promoted_iter {
167            shared_b.push(p.clone());
168        }
169        // Objects ascending: object-only (shared terms were emitted above).
170        let mut objects: Vec<String> = object_set.into_terms();
171        sort_terms(&mut objects);
172        for o in &objects {
173            if !subject_set.contains(o.as_str()) && !promoted_set.contains(o.as_str()) {
174                obj_b.push(o.clone());
175            }
176        }
177        for p in &predicates {
178            pred_b.push(p.clone());
179        }
180
181        // RDF-star: a quoted triple only ever appears in subject/object position,
182        // so a single prefix scan of those term sets detects the whole file.
183        let has_quoted_triples = subjects.iter().any(|s| s.starts_with("<<"))
184            || objects.iter().any(|o| o.starts_with("<<"));
185
186        let shared = shared_b.build();
187        let mut dict =
188            Dictionary::from_sections([shared, subj_b.build(), obj_b.build(), pred_b.build()]);
189        dict.has_quoted_triples = has_quoted_triples;
190        dict
191    }
192}
193
194/// The object terms a [`DictionaryBuilder`] observed: a set, or the
195/// occurrence counts frequent-object promotion needs.
196enum ObjectTerms {
197    Plain(HashSet<String>),
198    Counted(HashMap<String, u64>),
199}
200
201impl ObjectTerms {
202    fn contains(&self, term: &str) -> bool {
203        match self {
204            ObjectTerms::Plain(s) => s.contains(term),
205            ObjectTerms::Counted(m) => m.contains_key(term),
206        }
207    }
208
209    fn into_terms(self) -> Vec<String> {
210        match self {
211            ObjectTerms::Plain(s) => s.into_iter().collect(),
212            ObjectTerms::Counted(m) => m.into_keys().collect(),
213        }
214    }
215}
216
217/// Reusable per-window scratch for [`Dictionary::resolve_window`]: one job list
218/// per section (`(local_id, out_slot)` pairs) and the resolved-term output.
219/// Held across windows by the export loop so a window allocates only when a
220/// buffer must grow.
221#[derive(Default)]
222pub struct WindowResolver {
223    /// Jobs per dictionary section (shared, subject-only, object-only,
224    /// predicates): each `(section-local id, output slot)`.
225    jobs: [Vec<(u32, usize)>; 4],
226    /// Resolved terms, indexed by output slot `row * 3 + {0:s, 1:p, 2:o}`.
227    out: Vec<Option<String>>,
228}
229
230/// The most chunks one id-list prefetch ([`Dictionary::prefetch_subject_terms`],
231/// [`Dictionary::prefetch_node_terms`]) faults in a single batch. A batch holds
232/// every body it fetched until all are cached, so this bounds that transient
233/// (512 chunks of ≤64 KiB: ≤32 MiB) however many ids a caller passes; a longer
234/// list goes out in several batches.
235const PREFETCH_BATCH_CHUNKS: usize = 512;
236
237/// A read-only dictionary mapping terms ↔ role-specific IDs.
238///
239/// Holds the four sections as [`ChunkedSection`]s — local files keep each
240/// section as one resident chunk; a lazily-opened remote file faults
241/// individual chunks in on first touch. Lookups never re-parse a header.
242pub struct Dictionary {
243    /// shared, subjects, objects, predicates.
244    sections: [ChunkedSection; 4],
245    shared_len: u32,
246    /// Whether any term is an RDF-star quoted triple (`<< … >>`). Set at build
247    /// time (from the observed terms); `false` on the read path, where the
248    /// header's `FLAG_HAS_QUOTED_TRIPLES` is the source of truth instead.
249    has_quoted_triples: bool,
250    /// Faults the chunks of several sections in one batch (a ranged open);
251    /// `None` = section by section (local, or no batch reader).
252    multi_bulk: Option<MultiChunkBulkLoader>,
253}
254
255impl Dictionary {
256    /// Rebuild from four serialized sections (shared, subjects, objects,
257    /// predicates), e.g. when reading a `.rete` file. The four sections share one
258    /// [`ChunkCache`] (unlimited cap here, so every section body stays resident
259    /// exactly as before).
260    pub fn from_sections(sections: [Vec<u8>; 4]) -> Self {
261        let cache = ChunkCache::unlimited_arc();
262        let mut section_index = 0u8;
263        let chunked = sections.map(|bytes| {
264            let s = ChunkedSection::local(bytes, cache.clone(), section_index);
265            section_index += 1;
266            s
267        });
268        Self::from_chunked_sections(chunked)
269    }
270
271    /// Rebuild from four already-chunked sections (the remote lazy-open path).
272    pub fn from_chunked_sections(sections: [ChunkedSection; 4]) -> Self {
273        let shared_len = sections[0].term_count();
274        Dictionary {
275            sections,
276            shared_len,
277            has_quoted_triples: false,
278            multi_bulk: None,
279        }
280    }
281
282    /// Attach a cross-section chunk fetcher (see [`MultiChunkBulkLoader`]):
283    /// the term prefetchers then fault every section's missing chunks in one
284    /// batch instead of one batch per section.
285    pub(crate) fn with_multi_bulk_loader(mut self, loader: MultiChunkBulkLoader) -> Self {
286        self.multi_bulk = Some(loader);
287        self
288    }
289
290    /// Fault the chunks `want[si]` of each section, at most `batch` chunks
291    /// per read. With a cross-section fetcher every missing chunk goes out in
292    /// one batch (round trips: one, not one per section); without one, each
293    /// section batches its own. A failed batch leaves the chunks for the
294    /// per-chunk loader, which retries them lookup by lookup. Purely a cache
295    /// warmer: results never depend on it.
296    fn prefetch_sections(&self, want: &[Vec<usize>; 4], batch: usize) {
297        let batch = batch.max(2);
298        if let Some(multi) = &self.multi_bulk {
299            let pairs: Vec<(u8, usize)> = want
300                .iter()
301                .enumerate()
302                .flat_map(|(si, cis)| {
303                    self.sections[si]
304                        .missing_chunks(cis)
305                        .into_iter()
306                        .map(move |ci| (si as u8, ci))
307                })
308                .collect();
309            // A single missing chunk costs the same either way: leave it to
310            // the per-chunk loader, as a section's own batch does.
311            if pairs.len() < 2 {
312                return;
313            }
314            for part in pairs.chunks(batch) {
315                if let Some(bodies) = multi(part) {
316                    if bodies.len() == part.len() {
317                        for (&(si, ci), body) in part.iter().zip(bodies) {
318                            self.sections[si as usize].seed_chunk(ci, body);
319                        }
320                    }
321                }
322            }
323            return;
324        }
325        for (si, cis) in want.iter().enumerate() {
326            for part in cis.chunks(batch) {
327                self.sections[si].prefetch_chunks(part);
328            }
329        }
330    }
331
332    /// Total number of distinct terms across all four sections.
333    pub fn term_count(&self) -> u32 {
334        self.sections.iter().map(|s| s.term_count()).sum()
335    }
336
337    /// Set the byte cap of the shared chunk cache (bounded-export phase 2).
338    /// `u64::MAX` (the default from every open) means unlimited — no eviction,
339    /// residency identical to the pre-budget design. A finite cap evicts
340    /// least-recently-used chunk bodies down to it on every fault, so a full
341    /// dump's peak dictionary residency is bounded rather than the whole
342    /// decompressed dictionary. All four sections share one cache; setting it on
343    /// each is idempotent (they are the same `Arc`).
344    pub fn set_cache_cap(&self, cap: u64) {
345        for s in &self.sections {
346            s.cache().set_cap(cap);
347        }
348    }
349
350    /// Heap bytes the four sections' chunk directories hold for as long as
351    /// the dictionary is open (see [`ChunkedSection::directory_bytes`]).
352    ///
353    /// [`ChunkedSection::directory_bytes`]: crate::dict::ChunkedSection::directory_bytes
354    pub fn directory_bytes(&self) -> u64 {
355        self.sections.iter().map(|s| s.directory_bytes()).sum()
356    }
357
358    /// The current chunk-cache byte cap (`u64::MAX` = unlimited).
359    pub fn cache_cap(&self) -> u64 {
360        self.sections[0].cache().cap()
361    }
362
363    /// A snapshot of the shared chunk cache's observability counters — for
364    /// `RETE_OPEN_DEBUG` accounting and the eviction tests.
365    pub fn cache_stats(&self) -> crate::chunk_cache::CacheStats {
366        self.sections[0].cache().stats()
367    }
368
369    /// Does this dataset contain RDF-star quoted triples? Meaningful only for a
370    /// freshly built dictionary (a read-back one carries the truth in the file
371    /// header's `FLAG_HAS_QUOTED_TRIPLES`, not here).
372    pub fn has_quoted_triples(&self) -> bool {
373        self.has_quoted_triples
374    }
375
376    /// Did any lazy chunk fetch fail since this dictionary was opened — or
377    /// since the last [`reset_load_failure`](Self::reset_load_failure)?
378    pub fn load_incomplete(&self) -> bool {
379        self.sections.iter().any(|s| s.load_incomplete())
380    }
381
382    /// Forget recorded fetch failures across every section (see
383    /// `GraphIndex::reset_load_failure` — the per-query reset for resident
384    /// sessions). Failed chunks were never cached, so they simply retry.
385    pub fn reset_load_failure(&self) {
386        self.sections.iter().for_each(|s| s.reset_load_failure());
387    }
388
389    /// Batch-fault every unloaded chunk of every section (no-op for local
390    /// dictionaries). Callers about to resolve *every* term — export, dump —
391    /// call this once so the sweep coalesces into a few range reads instead
392    /// of one fetch per chunk.
393    pub fn prefetch_all(&self) {
394        for s in &self.sections {
395            s.prefetch_all();
396        }
397    }
398
399    /// Batch-fault just the chunks needed to resolve a bounded result set:
400    /// `node_ids` are unified-node ids (the `Val::Id(id)` with `id >= 0`),
401    /// `predicate_ids` are predicate-space ids. Groups them by `(section,
402    /// chunk)` and coalesces each section's faults into a few range reads —
403    /// turning "one request per distinct output term" into "a handful per
404    /// query". No-op for a local dictionary (every chunk is already resident).
405    pub fn prefetch_terms(&self, node_ids: &[u32], predicate_ids: &[u32]) {
406        self.prefetch_terms_batched(node_ids, predicate_ids, usize::MAX);
407    }
408
409    /// [`prefetch_terms`](Self::prefetch_terms), faulting at most `batch` chunks
410    /// per bulk read. A bulk read holds every chunk it fetches — the coalesced
411    /// span, the per-chunk slices and the decoded bodies — until all of them
412    /// are cached, so an unbatched prefetch of a scattered window materialized
413    /// up to thousands of chunks at once, outside every cache cap. A bounded
414    /// export batches it so that transient stays a few MB.
415    pub fn prefetch_terms_batched(&self, node_ids: &[u32], predicate_ids: &[u32], batch: usize) {
416        let mut want: [std::collections::BTreeSet<usize>; 4] = Default::default();
417        for &n in node_ids {
418            if let Some((si, ci)) = self.node_chunk(n) {
419                want[si].insert(ci);
420            }
421        }
422        for &p in predicate_ids {
423            if let Some(ci) = self.sections[3].chunk_of_id(p) {
424                want[3].insert(ci);
425            }
426        }
427        let want: [Vec<usize>; 4] = want.map(|set| set.into_iter().collect());
428        self.prefetch_sections(&want, batch);
429    }
430
431    /// The `(section, chunk)` holding a unified node id — mirrors the section
432    /// routing in [`node_term`](Self::node_term).
433    fn node_chunk(&self, node: u32) -> Option<(usize, usize)> {
434        let su = self.subject_only_count();
435        let (si, local) = if node < self.shared_len + su {
436            let id = node + 1; // subject_term(id)
437            if id <= self.shared_len {
438                (0, id)
439            } else {
440                (1, id - self.shared_len)
441            }
442        } else {
443            let id = node + 1 - su; // object_term(id)
444            if id <= self.shared_len {
445                (0, id)
446            } else {
447                (2, id - self.shared_len)
448            }
449        };
450        Some((si, self.sections[si].chunk_of_id(local)?))
451    }
452
453    /// Number of shared terms `S`.
454    pub fn shared_count(&self) -> u32 {
455        self.shared_len
456    }
457
458    /// Number of subject-only terms `Su`.
459    pub fn subject_only_count(&self) -> u32 {
460        self.sections[1].term_count()
461    }
462
463    /// Number of object-only terms `Oo`.
464    pub fn object_only_count(&self) -> u32 {
465        self.sections[2].term_count()
466    }
467
468    /// Number of predicate terms.
469    pub fn predicate_count(&self) -> u32 {
470        self.sections[3].term_count()
471    }
472
473    /// [`subject_term`](Self::subject_term) for many ids at once: one
474    /// chunk-ordered, single-pass walk per section
475    /// ([`ChunkedSection::resolve_into`]) instead of a fault and a run walk per
476    /// id — the difference between a whole-dictionary pass taking minutes and
477    /// taking hours. `out[i]` is exactly what `subject_term(ids[i])` returns.
478    ///
479    /// [`ChunkedSection::resolve_into`]: crate::dict::ChunkedSection::resolve_into
480    pub fn subject_terms(&self, ids: &[SubjectId]) -> Vec<Option<String>> {
481        self.role_terms(ids, 1)
482    }
483
484    /// [`object_term`](Self::object_term) for many ids at once — see
485    /// [`subject_terms`](Self::subject_terms).
486    pub fn object_terms(&self, ids: &[ObjectId]) -> Vec<Option<String>> {
487        self.role_terms(ids, 2)
488    }
489
490    /// Shared ids route to section 0, the rest to `own` (1 subjects, 2 objects)
491    /// with the shared block subtracted — the routing of `subject_term` /
492    /// `object_term`.
493    fn role_terms(&self, ids: &[u32], own: usize) -> Vec<Option<String>> {
494        let mut out = vec![None; ids.len()];
495        let mut shared = Vec::new();
496        let mut rest = Vec::new();
497        for (slot, &id) in ids.iter().enumerate() {
498            if id <= self.shared_len {
499                shared.push((id, slot));
500            } else {
501                rest.push((id - self.shared_len, slot));
502            }
503        }
504        self.sections[0].resolve_into(&mut shared, &mut out);
505        self.sections[own].resolve_into(&mut rest, &mut out);
506        out
507    }
508
509    // --- unified node space (for graph algorithms / the pyramid) ----------
510    //
511    // Subjects and objects use *separate* ID spaces that overlap on shared
512    // terms; the pyramid needs one node per distinct term. We lay them out as:
513    //   [0 .. S)            shared
514    //   [S .. S+Su)         subject-only
515    //   [S+Su .. S+Su+Oo)   object-only
516
517    /// Total distinct graph nodes `N = S + Su + Oo`.
518    pub fn node_count(&self) -> u32 {
519        self.shared_len + self.subject_only_count() + self.object_only_count()
520    }
521
522    /// Unified node ID for a subject-role ID. Shared and subject-only IDs are
523    /// already contiguous (`1..=S+Su`), so this is just `sid - 1`.
524    pub fn subject_node(&self, sid: SubjectId) -> NodeId {
525        // saturating_sub: a corrupt block may carry id 0; valid ids are ≥1 so this
526        // is identical for well-formed files but never underflow-panics.
527        sid.saturating_sub(1)
528    }
529
530    /// Unified node ID for an object-role ID: shared stay at `oid-1`, object-only
531    /// IDs are shifted past the subject-only block.
532    pub fn object_node(&self, oid: ObjectId) -> NodeId {
533        if oid <= self.shared_len {
534            oid.saturating_sub(1)
535        } else {
536            oid.saturating_sub(1) + self.subject_only_count()
537        }
538    }
539
540    /// Resolve a unified node ID back to its term.
541    pub fn node_term(&self, node: NodeId) -> Option<String> {
542        let su = self.subject_only_count();
543        if node < self.shared_len + su {
544            self.subject_term(node + 1)
545        } else {
546            self.object_term(node + 1 - su)
547        }
548    }
549
550    /// Batch-fault the dictionary chunks that decoding these subject-role ids
551    /// will touch — one coalesced read per section instead of a blocking round
552    /// trip per id. Purely a cache warmer (resident sections no-op).
553    pub fn prefetch_subject_terms(&self, ids: &[SubjectId]) {
554        let mut per_section: [Vec<usize>; 2] = Default::default();
555        for &id in ids {
556            let (si, local) = if id <= self.shared_len {
557                (0usize, id)
558            } else {
559                (1, id - self.shared_len)
560            };
561            if let Some(ci) = self.sections[si].chunk_of_term(local) {
562                per_section[si].push(ci);
563            }
564        }
565        let [shared, subjects] = per_section;
566        self.prefetch_sorted([shared, subjects, Vec::new(), Vec::new()]);
567    }
568
569    /// Sort, dedup and fault each section's chunk list (one batch across
570    /// sections when the dictionary has a cross-section fetcher), at most
571    /// [`PREFETCH_BATCH_CHUNKS`] chunks per read.
572    fn prefetch_sorted(&self, mut want: [Vec<usize>; 4]) {
573        for cis in &mut want {
574            cis.sort_unstable();
575            cis.dedup();
576        }
577        self.prefetch_sections(&want, PREFETCH_BATCH_CHUNKS);
578    }
579
580    /// [`prefetch_subject_terms`](Self::prefetch_subject_terms) for unified
581    /// node ids — the id space solution rows carry. Warms every chunk a batch
582    /// of row decodes (a FILTER's string ops, the projection) is about to
583    /// fault, using [`node_term`](Self::node_term)'s exact routing.
584    pub fn prefetch_node_terms(&self, nodes: &[NodeId]) {
585        let su = self.subject_only_count();
586        let mut per_section: [Vec<usize>; 3] = Default::default();
587        for &node in nodes {
588            let (si, local) = if node < self.shared_len + su {
589                let sid = node + 1;
590                if sid <= self.shared_len {
591                    (0usize, sid)
592                } else {
593                    (1, sid - self.shared_len)
594                }
595            } else {
596                let oid = node + 1 - su;
597                if oid <= self.shared_len {
598                    (0, oid)
599                } else {
600                    (2, oid - self.shared_len)
601                }
602            };
603            if let Some(ci) = self.sections[si].chunk_of_term(local) {
604                per_section[si].push(ci);
605            }
606        }
607        let [shared, subjects, objects] = per_section;
608        self.prefetch_sorted([shared, subjects, objects, Vec::new()]);
609    }
610
611    /// Unified node ID for a term, resolving via subject role then object role.
612    /// A string literal also matches its other spelling (`"x"` ↔
613    /// `"x"^^xsd:string`, one RDF 1.1 term; see [`Self::subject_id`]).
614    pub fn node_of_term(&self, term: &str) -> Option<NodeId> {
615        let exact = |t: &str| {
616            self.subject_id_exact(t)
617                .map(|sid| self.subject_node(sid))
618                .or_else(|| self.object_id_exact(t).map(|oid| self.object_node(oid)))
619        };
620        exact(term).or_else(|| exact(&crate::terms::string_literal_alias(term)?))
621    }
622
623    /// Subject-role ID of a node, or `None` if the node never appears as a
624    /// subject (an object-only term).
625    pub fn node_as_subject_id(&self, node: NodeId) -> Option<SubjectId> {
626        if node < self.shared_len + self.subject_only_count() {
627            Some(node + 1)
628        } else {
629            None
630        }
631    }
632
633    /// Object-role ID of a node, or `None` if the node never appears as an
634    /// object (a subject-only term).
635    pub fn node_as_object_id(&self, node: NodeId) -> Option<ObjectId> {
636        let (s, su) = (self.shared_len, self.subject_only_count());
637        if node < s {
638            Some(node + 1) // shared
639        } else if node >= s + su {
640            Some(node + 1 - su) // object-only
641        } else {
642            None // subject-only
643        }
644    }
645
646    // --- term -> id -------------------------------------------------------
647
648    /// Subject-role ID for `term`: shared `1..=S`, else subject-only `S+1..`.
649    ///
650    /// A string literal that is not found under its own spelling is looked up
651    /// under the other (`"x"` ↔ `"x"^^xsd:string`): RDF 1.1 makes them one
652    /// term, ingest keeps only the plain one, and a file built before it did
653    /// may hold the typed one. The second lookup runs only on a miss, and the
654    /// two spellings sort side by side, so it reads no further chunk.
655    pub fn subject_id(&self, term: &str) -> Option<SubjectId> {
656        self.subject_id_exact(term)
657            .or_else(|| self.subject_id_exact(&crate::terms::string_literal_alias(term)?))
658    }
659
660    /// Object-role ID for `term`, with the same string-literal fallback as
661    /// [`Self::subject_id`].
662    pub fn object_id(&self, term: &str) -> Option<ObjectId> {
663        self.object_id_exact(term)
664            .or_else(|| self.object_id_exact(&crate::terms::string_literal_alias(term)?))
665    }
666
667    fn subject_id_exact(&self, term: &str) -> Option<SubjectId> {
668        if let Some(id) = self.sections[0].id(term) {
669            return Some(id);
670        }
671        self.sections[1].id(term).map(|id| self.shared_len + id)
672    }
673
674    /// Object-role ID for `term`: shared `1..=S`, else object-only `S+1..`.
675    fn object_id_exact(&self, term: &str) -> Option<ObjectId> {
676        if let Some(id) = self.sections[0].id(term) {
677            return Some(id);
678        }
679        self.sections[2].id(term).map(|id| self.shared_len + id)
680    }
681
682    /// Predicate ID for `term` (independent space).
683    pub fn predicate_id(&self, term: &str) -> Option<PredicateId> {
684        self.sections[3].id(term)
685    }
686
687    // --- id -> term -------------------------------------------------------
688
689    pub fn subject_term(&self, id: SubjectId) -> Option<String> {
690        if id <= self.shared_len {
691            self.sections[0].term(id)
692        } else {
693            self.sections[1].term(id - self.shared_len)
694        }
695    }
696
697    pub fn object_term(&self, id: ObjectId) -> Option<String> {
698        if id <= self.shared_len {
699            self.sections[0].term(id)
700        } else {
701            self.sections[2].term(id - self.shared_len)
702        }
703    }
704
705    pub fn predicate_term(&self, id: PredicateId) -> Option<String> {
706        self.sections[3].term(id)
707    }
708
709    /// Encode a `(s, p, o)` term triple to its `(subject_id, predicate_id,
710    /// object_id)`. `None` if any term is unknown.
711    pub fn encode(&self, s: &str, p: &str, o: &str) -> Option<(SubjectId, PredicateId, ObjectId)> {
712        Some((
713            self.subject_id(s)?,
714            self.predicate_id(p)?,
715            self.object_id(o)?,
716        ))
717    }
718
719    /// Resolve a window of `(subject_id, predicate_id, object_id)` triples in
720    /// **chunk order** and emit each fully-resolved row through `emit` in the
721    /// window's **original order**.
722    ///
723    /// This is the batch twin of resolving each row with
724    /// [`subject_term`](Self::subject_term) /
725    /// [`predicate_term`](Self::predicate_term) /
726    /// [`object_term`](Self::object_term) and emitting on `(Some, Some, Some)`
727    /// — the exact thing it replaces inside `Rete::dump_filtered_each`. A row is
728    /// emitted only when all three of its terms resolve, and the bytes of each
729    /// term are identical to the per-row path, so the dump stays
730    /// byte-for-byte the same.
731    ///
732    /// What changes is *how* the terms are decoded. Each row contributes three
733    /// jobs to the section that owns its id (shared/subject-only for the
734    /// subject, predicates for the predicate, shared/object-only for the
735    /// object); each section then resolves its jobs in one chunk-ordered,
736    /// single-pass walk ([`ChunkedSection::resolve_into`]). A window whose
737    /// object ids scatter across the object-only section therefore touches each
738    /// chunk once — one faulted body, one forward walk — instead of the
739    /// scattered re-faults and per-row re-walks the row-order path performed.
740    ///
741    /// `w` carries the per-window scratch buffers so back-to-back windows cost
742    /// no allocation beyond growth.
743    ///
744    /// [`ChunkedSection::resolve_into`]: crate::dict::ChunkedSection::resolve_into
745    pub fn resolve_window(
746        &self,
747        ids: &[(SubjectId, PredicateId, ObjectId)],
748        w: &mut WindowResolver,
749        mut emit: impl FnMut(&str, &str, &str),
750    ) {
751        for j in &mut w.jobs {
752            j.clear();
753        }
754        w.out.clear();
755        w.out.resize(ids.len() * 3, None);
756        for (i, &(s, p, o)) in ids.iter().enumerate() {
757            // Subject role: shared (section 0) if `id <= S`, else subject-only
758            // (section 1) with the shared block subtracted — the exact routing
759            // of `subject_term`.
760            if s <= self.shared_len {
761                w.jobs[0].push((s, i * 3));
762            } else {
763                w.jobs[1].push((s - self.shared_len, i * 3));
764            }
765            // Predicate role: its own section (3).
766            w.jobs[3].push((p, i * 3 + 1));
767            // Object role: shared (section 0) if `id <= S`, else object-only
768            // (section 2) — the exact routing of `object_term`.
769            if o <= self.shared_len {
770                w.jobs[0].push((o, i * 3 + 2));
771            } else {
772                w.jobs[2].push((o - self.shared_len, i * 3 + 2));
773            }
774        }
775        for si in 0..4 {
776            self.sections[si].resolve_into(&mut w.jobs[si], &mut w.out);
777        }
778        for i in 0..ids.len() {
779            if let (Some(s), Some(p), Some(o)) =
780                (&w.out[i * 3], &w.out[i * 3 + 1], &w.out[i * 3 + 2])
781            {
782                emit(s, p, o);
783            }
784        }
785    }
786
787    /// The four serialized sections (header + body each), for the file writer.
788    pub fn sections(&self) -> [Vec<u8>; 4] {
789        [
790            self.sections[0].raw_section_bytes(),
791            self.sections[1].raw_section_bytes(),
792            self.sections[2].raw_section_bytes(),
793            self.sections[3].raw_section_bytes(),
794        ]
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    fn dict() -> Dictionary {
803        let mut b = DictionaryBuilder::new();
804        // Alice knows Bob; Bob knows Carol. So Bob is both subject and object
805        // (shared); Alice is subject-only; Carol is object-only.
806        b.observe("Alice", "knows", "Bob");
807        b.observe("Bob", "knows", "Carol");
808        b.build()
809    }
810
811    #[test]
812    fn shared_term_has_same_id_in_both_roles() {
813        let d = dict();
814        assert_eq!(d.shared_count(), 1); // just "Bob"
815        let sid = d.subject_id("Bob").unwrap();
816        let oid = d.object_id("Bob").unwrap();
817        assert_eq!(sid, oid, "shared term must share its ID across roles");
818        assert_eq!(sid, 1, "shared terms get the lowest IDs");
819    }
820
821    #[test]
822    fn role_specific_terms_round_trip() {
823        let d = dict();
824        // subject-only Alice
825        let a = d.subject_id("Alice").unwrap();
826        assert!(a > d.shared_count());
827        assert_eq!(d.subject_term(a).as_deref(), Some("Alice"));
828        // object-only Carol
829        let c = d.object_id("Carol").unwrap();
830        assert!(c > d.shared_count());
831        assert_eq!(d.object_term(c).as_deref(), Some("Carol"));
832        // predicate
833        let k = d.predicate_id("knows").unwrap();
834        assert_eq!(d.predicate_term(k).as_deref(), Some("knows"));
835        // Alice never appears as object, Carol never as subject.
836        assert_eq!(d.object_id("Alice"), None);
837        assert_eq!(d.subject_id("Carol"), None);
838    }
839
840    #[test]
841    fn encode_full_triples() {
842        let d = dict();
843        let t1 = d.encode("Alice", "knows", "Bob").unwrap();
844        let t2 = d.encode("Bob", "knows", "Carol").unwrap();
845        // Bob is object in t1 and subject in t2 — same ID both times.
846        assert_eq!(t1.2, t2.0);
847        assert!(d.encode("Nobody", "knows", "Bob").is_none());
848    }
849}