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::chunk_cache::ChunkCache;
13use crate::dict::{ChunkedSection, DictSectionBuilder};
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}
42
43impl DictionaryBuilder {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Record one triple's terms (IDs are assigned later, in [`Self::build`]).
49    /// We probe with the borrowed `&str` first and only allocate a `String` for
50    /// a genuinely new term — so a graph of N triples does far fewer than 3N
51    /// allocations (each subject/predicate recurs across many triples).
52    pub fn observe(&mut self, subject: &str, predicate: &str, object: &str) {
53        if !self.subjects.contains(subject) {
54            self.subjects.insert(subject.to_string());
55        }
56        if !self.objects.contains(object) {
57            self.objects.insert(object.to_string());
58        }
59        if !self.predicates.contains(predicate) {
60            self.predicates.insert(predicate.to_string());
61        }
62    }
63
64    pub fn build(self) -> Dictionary {
65        // Sort each role's terms once (ascending) — identical output order to
66        // the old BTreeSet iteration, but without the per-insert tree cost.
67        let mut subjects: Vec<String> = self.subjects.into_iter().collect();
68        let mut predicates: Vec<String> = self.predicates.into_iter().collect();
69        let object_set = self.objects; // kept for the shared (subject ∩ object) test
70        sort_terms(&mut subjects);
71        sort_terms(&mut predicates);
72
73        // A term is "shared" when it appears as both a subject and an object.
74        let subject_set: HashSet<&str> = subjects.iter().map(String::as_str).collect();
75
76        let mut shared_b = DictSectionBuilder::new();
77        let mut subj_b = DictSectionBuilder::new();
78        let mut obj_b = DictSectionBuilder::new();
79        let mut pred_b = DictSectionBuilder::new();
80
81        // Subjects ascending: shared (also an object) vs subject-only.
82        for s in &subjects {
83            if object_set.contains(s) {
84                shared_b.push(s.clone());
85            } else {
86                subj_b.push(s.clone());
87            }
88        }
89        // Objects ascending: object-only (shared terms were emitted above).
90        let mut objects: Vec<String> = object_set.into_iter().collect();
91        sort_terms(&mut objects);
92        for o in &objects {
93            if !subject_set.contains(o.as_str()) {
94                obj_b.push(o.clone());
95            }
96        }
97        for p in &predicates {
98            pred_b.push(p.clone());
99        }
100
101        // RDF-star: a quoted triple only ever appears in subject/object position,
102        // so a single prefix scan of those term sets detects the whole file.
103        let has_quoted_triples = subjects.iter().any(|s| s.starts_with("<<"))
104            || objects.iter().any(|o| o.starts_with("<<"));
105
106        let shared = shared_b.build();
107        let mut dict =
108            Dictionary::from_sections([shared, subj_b.build(), obj_b.build(), pred_b.build()]);
109        dict.has_quoted_triples = has_quoted_triples;
110        dict
111    }
112}
113
114/// Reusable per-window scratch for [`Dictionary::resolve_window`]: one job list
115/// per section (`(local_id, out_slot)` pairs) and the resolved-term output.
116/// Held across windows by the export loop so a window allocates only when a
117/// buffer must grow.
118#[derive(Default)]
119pub struct WindowResolver {
120    /// Jobs per dictionary section (shared, subject-only, object-only,
121    /// predicates): each `(section-local id, output slot)`.
122    jobs: [Vec<(u32, usize)>; 4],
123    /// Resolved terms, indexed by output slot `row * 3 + {0:s, 1:p, 2:o}`.
124    out: Vec<Option<String>>,
125}
126
127/// A read-only dictionary mapping terms ↔ role-specific IDs.
128///
129/// Holds the four sections as [`ChunkedSection`]s — local files keep each
130/// section as one resident chunk; a lazily-opened remote file faults
131/// individual chunks in on first touch. Lookups never re-parse a header.
132pub struct Dictionary {
133    /// shared, subjects, objects, predicates.
134    sections: [ChunkedSection; 4],
135    shared_len: u32,
136    /// Whether any term is an RDF-star quoted triple (`<< … >>`). Set at build
137    /// time (from the observed terms); `false` on the read path, where the
138    /// header's `FLAG_HAS_QUOTED_TRIPLES` is the source of truth instead.
139    has_quoted_triples: bool,
140}
141
142impl Dictionary {
143    /// Rebuild from four serialized sections (shared, subjects, objects,
144    /// predicates), e.g. when reading a `.rete` file. The four sections share one
145    /// [`ChunkCache`] (unlimited cap here, so every section body stays resident
146    /// exactly as before).
147    pub fn from_sections(sections: [Vec<u8>; 4]) -> Self {
148        let cache = ChunkCache::unlimited_arc();
149        let mut section_index = 0u8;
150        let chunked = sections.map(|bytes| {
151            let s = ChunkedSection::local(bytes, cache.clone(), section_index);
152            section_index += 1;
153            s
154        });
155        Self::from_chunked_sections(chunked)
156    }
157
158    /// Rebuild from four already-chunked sections (the remote lazy-open path).
159    pub fn from_chunked_sections(sections: [ChunkedSection; 4]) -> Self {
160        let shared_len = sections[0].term_count();
161        Dictionary {
162            sections,
163            shared_len,
164            has_quoted_triples: false,
165        }
166    }
167
168    /// Total number of distinct terms across all four sections.
169    pub fn term_count(&self) -> u32 {
170        self.sections.iter().map(|s| s.term_count()).sum()
171    }
172
173    /// Set the byte cap of the shared chunk cache (bounded-export phase 2).
174    /// `u64::MAX` (the default from every open) means unlimited — no eviction,
175    /// residency identical to the pre-budget design. A finite cap evicts
176    /// least-recently-used chunk bodies down to it on every fault, so a full
177    /// dump's peak dictionary residency is bounded rather than the whole
178    /// decompressed dictionary. All four sections share one cache; setting it on
179    /// each is idempotent (they are the same `Arc`).
180    pub fn set_cache_cap(&self, cap: u64) {
181        for s in &self.sections {
182            s.cache().set_cap(cap);
183        }
184    }
185
186    /// Heap bytes the four sections' chunk directories hold for as long as
187    /// the dictionary is open (see [`ChunkedSection::directory_bytes`]).
188    ///
189    /// [`ChunkedSection::directory_bytes`]: crate::dict::ChunkedSection::directory_bytes
190    pub fn directory_bytes(&self) -> u64 {
191        self.sections.iter().map(|s| s.directory_bytes()).sum()
192    }
193
194    /// The current chunk-cache byte cap (`u64::MAX` = unlimited).
195    pub fn cache_cap(&self) -> u64 {
196        self.sections[0].cache().cap()
197    }
198
199    /// A snapshot of the shared chunk cache's observability counters — for
200    /// `RETE_OPEN_DEBUG` accounting and the eviction tests.
201    pub fn cache_stats(&self) -> crate::chunk_cache::CacheStats {
202        self.sections[0].cache().stats()
203    }
204
205    /// Does this dataset contain RDF-star quoted triples? Meaningful only for a
206    /// freshly built dictionary (a read-back one carries the truth in the file
207    /// header's `FLAG_HAS_QUOTED_TRIPLES`, not here).
208    pub fn has_quoted_triples(&self) -> bool {
209        self.has_quoted_triples
210    }
211
212    /// Did any lazy chunk fetch fail since this dictionary was opened — or
213    /// since the last [`reset_load_failure`](Self::reset_load_failure)?
214    pub fn load_incomplete(&self) -> bool {
215        self.sections.iter().any(|s| s.load_incomplete())
216    }
217
218    /// Forget recorded fetch failures across every section (see
219    /// `GraphIndex::reset_load_failure` — the per-query reset for resident
220    /// sessions). Failed chunks were never cached, so they simply retry.
221    pub fn reset_load_failure(&self) {
222        self.sections.iter().for_each(|s| s.reset_load_failure());
223    }
224
225    /// Batch-fault every unloaded chunk of every section (no-op for local
226    /// dictionaries). Callers about to resolve *every* term — export, dump —
227    /// call this once so the sweep coalesces into a few range reads instead
228    /// of one fetch per chunk.
229    pub fn prefetch_all(&self) {
230        for s in &self.sections {
231            s.prefetch_all();
232        }
233    }
234
235    /// Batch-fault just the chunks needed to resolve a bounded result set:
236    /// `node_ids` are unified-node ids (the `Val::Id(id)` with `id >= 0`),
237    /// `predicate_ids` are predicate-space ids. Groups them by `(section,
238    /// chunk)` and coalesces each section's faults into a few range reads —
239    /// turning "one request per distinct output term" into "a handful per
240    /// query". No-op for a local dictionary (every chunk is already resident).
241    pub fn prefetch_terms(&self, node_ids: &[u32], predicate_ids: &[u32]) {
242        self.prefetch_terms_batched(node_ids, predicate_ids, usize::MAX);
243    }
244
245    /// [`prefetch_terms`](Self::prefetch_terms), faulting at most `batch` chunks
246    /// per bulk read. A bulk read holds every chunk it fetches — the coalesced
247    /// span, the per-chunk slices and the decoded bodies — until all of them
248    /// are cached, so an unbatched prefetch of a scattered window materialized
249    /// up to thousands of chunks at once, outside every cache cap. A bounded
250    /// export batches it so that transient stays a few MB.
251    pub fn prefetch_terms_batched(&self, node_ids: &[u32], predicate_ids: &[u32], batch: usize) {
252        let batch = batch.max(2);
253        let mut want: [std::collections::BTreeSet<usize>; 4] = Default::default();
254        for &n in node_ids {
255            if let Some((si, ci)) = self.node_chunk(n) {
256                want[si].insert(ci);
257            }
258        }
259        for &p in predicate_ids {
260            if let Some(ci) = self.sections[3].chunk_of_id(p) {
261                want[3].insert(ci);
262            }
263        }
264        for (si, set) in want.iter().enumerate() {
265            if set.len() >= 2 {
266                let cis: Vec<usize> = set.iter().copied().collect();
267                for part in cis.chunks(batch) {
268                    self.sections[si].prefetch_chunks(part);
269                }
270            }
271        }
272    }
273
274    /// The `(section, chunk)` holding a unified node id — mirrors the section
275    /// routing in [`node_term`](Self::node_term).
276    fn node_chunk(&self, node: u32) -> Option<(usize, usize)> {
277        let su = self.subject_only_count();
278        let (si, local) = if node < self.shared_len + su {
279            let id = node + 1; // subject_term(id)
280            if id <= self.shared_len {
281                (0, id)
282            } else {
283                (1, id - self.shared_len)
284            }
285        } else {
286            let id = node + 1 - su; // object_term(id)
287            if id <= self.shared_len {
288                (0, id)
289            } else {
290                (2, id - self.shared_len)
291            }
292        };
293        Some((si, self.sections[si].chunk_of_id(local)?))
294    }
295
296    /// Number of shared terms `S`.
297    pub fn shared_count(&self) -> u32 {
298        self.shared_len
299    }
300
301    /// Number of subject-only terms `Su`.
302    pub fn subject_only_count(&self) -> u32 {
303        self.sections[1].term_count()
304    }
305
306    /// Number of object-only terms `Oo`.
307    pub fn object_only_count(&self) -> u32 {
308        self.sections[2].term_count()
309    }
310
311    // --- unified node space (for graph algorithms / the pyramid) ----------
312    //
313    // Subjects and objects use *separate* ID spaces that overlap on shared
314    // terms; the pyramid needs one node per distinct term. We lay them out as:
315    //   [0 .. S)            shared
316    //   [S .. S+Su)         subject-only
317    //   [S+Su .. S+Su+Oo)   object-only
318
319    /// Total distinct graph nodes `N = S + Su + Oo`.
320    pub fn node_count(&self) -> u32 {
321        self.shared_len + self.subject_only_count() + self.object_only_count()
322    }
323
324    /// Unified node ID for a subject-role ID. Shared and subject-only IDs are
325    /// already contiguous (`1..=S+Su`), so this is just `sid - 1`.
326    pub fn subject_node(&self, sid: SubjectId) -> NodeId {
327        // saturating_sub: a corrupt block may carry id 0; valid ids are ≥1 so this
328        // is identical for well-formed files but never underflow-panics.
329        sid.saturating_sub(1)
330    }
331
332    /// Unified node ID for an object-role ID: shared stay at `oid-1`, object-only
333    /// IDs are shifted past the subject-only block.
334    pub fn object_node(&self, oid: ObjectId) -> NodeId {
335        if oid <= self.shared_len {
336            oid.saturating_sub(1)
337        } else {
338            oid.saturating_sub(1) + self.subject_only_count()
339        }
340    }
341
342    /// Resolve a unified node ID back to its term.
343    pub fn node_term(&self, node: NodeId) -> Option<String> {
344        let su = self.subject_only_count();
345        if node < self.shared_len + su {
346            self.subject_term(node + 1)
347        } else {
348            self.object_term(node + 1 - su)
349        }
350    }
351
352    /// Batch-fault the dictionary chunks that decoding these subject-role ids
353    /// will touch — one coalesced read per section instead of a blocking round
354    /// trip per id. Purely a cache warmer (resident sections no-op).
355    pub fn prefetch_subject_terms(&self, ids: &[SubjectId]) {
356        let mut per_section: [Vec<usize>; 2] = Default::default();
357        for &id in ids {
358            let (si, local) = if id <= self.shared_len {
359                (0usize, id)
360            } else {
361                (1, id - self.shared_len)
362            };
363            if let Some(ci) = self.sections[si].chunk_of_term(local) {
364                per_section[si].push(ci);
365            }
366        }
367        for (si, cis) in per_section.iter_mut().enumerate() {
368            cis.sort_unstable();
369            cis.dedup();
370            self.sections[si].prefetch_chunks(cis);
371        }
372    }
373
374    /// [`prefetch_subject_terms`](Self::prefetch_subject_terms) for unified
375    /// node ids — the id space solution rows carry. Warms every chunk a batch
376    /// of row decodes (a FILTER's string ops, the projection) is about to
377    /// fault, using [`node_term`](Self::node_term)'s exact routing.
378    pub fn prefetch_node_terms(&self, nodes: &[NodeId]) {
379        let su = self.subject_only_count();
380        let mut per_section: [Vec<usize>; 3] = Default::default();
381        for &node in nodes {
382            let (si, local) = if node < self.shared_len + su {
383                let sid = node + 1;
384                if sid <= self.shared_len {
385                    (0usize, sid)
386                } else {
387                    (1, sid - self.shared_len)
388                }
389            } else {
390                let oid = node + 1 - su;
391                if oid <= self.shared_len {
392                    (0, oid)
393                } else {
394                    (2, oid - self.shared_len)
395                }
396            };
397            if let Some(ci) = self.sections[si].chunk_of_term(local) {
398                per_section[si].push(ci);
399            }
400        }
401        for (si, cis) in per_section.iter_mut().enumerate() {
402            cis.sort_unstable();
403            cis.dedup();
404            self.sections[si].prefetch_chunks(cis);
405        }
406    }
407
408    /// Unified node ID for a term, resolving via subject role then object role.
409    pub fn node_of_term(&self, term: &str) -> Option<NodeId> {
410        if let Some(sid) = self.subject_id(term) {
411            return Some(self.subject_node(sid));
412        }
413        self.object_id(term).map(|oid| self.object_node(oid))
414    }
415
416    /// Subject-role ID of a node, or `None` if the node never appears as a
417    /// subject (an object-only term).
418    pub fn node_as_subject_id(&self, node: NodeId) -> Option<SubjectId> {
419        if node < self.shared_len + self.subject_only_count() {
420            Some(node + 1)
421        } else {
422            None
423        }
424    }
425
426    /// Object-role ID of a node, or `None` if the node never appears as an
427    /// object (a subject-only term).
428    pub fn node_as_object_id(&self, node: NodeId) -> Option<ObjectId> {
429        let (s, su) = (self.shared_len, self.subject_only_count());
430        if node < s {
431            Some(node + 1) // shared
432        } else if node >= s + su {
433            Some(node + 1 - su) // object-only
434        } else {
435            None // subject-only
436        }
437    }
438
439    // --- term -> id -------------------------------------------------------
440
441    /// Subject-role ID for `term`: shared `1..=S`, else subject-only `S+1..`.
442    pub fn subject_id(&self, term: &str) -> Option<SubjectId> {
443        if let Some(id) = self.sections[0].id(term) {
444            return Some(id);
445        }
446        self.sections[1].id(term).map(|id| self.shared_len + id)
447    }
448
449    /// Object-role ID for `term`: shared `1..=S`, else object-only `S+1..`.
450    pub fn object_id(&self, term: &str) -> Option<ObjectId> {
451        if let Some(id) = self.sections[0].id(term) {
452            return Some(id);
453        }
454        self.sections[2].id(term).map(|id| self.shared_len + id)
455    }
456
457    /// Predicate ID for `term` (independent space).
458    pub fn predicate_id(&self, term: &str) -> Option<PredicateId> {
459        self.sections[3].id(term)
460    }
461
462    // --- id -> term -------------------------------------------------------
463
464    pub fn subject_term(&self, id: SubjectId) -> Option<String> {
465        if id <= self.shared_len {
466            self.sections[0].term(id)
467        } else {
468            self.sections[1].term(id - self.shared_len)
469        }
470    }
471
472    pub fn object_term(&self, id: ObjectId) -> Option<String> {
473        if id <= self.shared_len {
474            self.sections[0].term(id)
475        } else {
476            self.sections[2].term(id - self.shared_len)
477        }
478    }
479
480    pub fn predicate_term(&self, id: PredicateId) -> Option<String> {
481        self.sections[3].term(id)
482    }
483
484    /// Encode a `(s, p, o)` term triple to its `(subject_id, predicate_id,
485    /// object_id)`. `None` if any term is unknown.
486    pub fn encode(&self, s: &str, p: &str, o: &str) -> Option<(SubjectId, PredicateId, ObjectId)> {
487        Some((
488            self.subject_id(s)?,
489            self.predicate_id(p)?,
490            self.object_id(o)?,
491        ))
492    }
493
494    /// Resolve a window of `(subject_id, predicate_id, object_id)` triples in
495    /// **chunk order** and emit each fully-resolved row through `emit` in the
496    /// window's **original order**.
497    ///
498    /// This is the batch twin of resolving each row with
499    /// [`subject_term`](Self::subject_term) /
500    /// [`predicate_term`](Self::predicate_term) /
501    /// [`object_term`](Self::object_term) and emitting on `(Some, Some, Some)`
502    /// — the exact thing it replaces inside `Rete::dump_filtered_each`. A row is
503    /// emitted only when all three of its terms resolve, and the bytes of each
504    /// term are identical to the per-row path, so the dump stays
505    /// byte-for-byte the same.
506    ///
507    /// What changes is *how* the terms are decoded. Each row contributes three
508    /// jobs to the section that owns its id (shared/subject-only for the
509    /// subject, predicates for the predicate, shared/object-only for the
510    /// object); each section then resolves its jobs in one chunk-ordered,
511    /// single-pass walk ([`ChunkedSection::resolve_into`]). A window whose
512    /// object ids scatter across the object-only section therefore touches each
513    /// chunk once — one faulted body, one forward walk — instead of the
514    /// scattered re-faults and per-row re-walks the row-order path performed.
515    ///
516    /// `w` carries the per-window scratch buffers so back-to-back windows cost
517    /// no allocation beyond growth.
518    ///
519    /// [`ChunkedSection::resolve_into`]: crate::dict::ChunkedSection::resolve_into
520    pub fn resolve_window(
521        &self,
522        ids: &[(SubjectId, PredicateId, ObjectId)],
523        w: &mut WindowResolver,
524        mut emit: impl FnMut(&str, &str, &str),
525    ) {
526        for j in &mut w.jobs {
527            j.clear();
528        }
529        w.out.clear();
530        w.out.resize(ids.len() * 3, None);
531        for (i, &(s, p, o)) in ids.iter().enumerate() {
532            // Subject role: shared (section 0) if `id <= S`, else subject-only
533            // (section 1) with the shared block subtracted — the exact routing
534            // of `subject_term`.
535            if s <= self.shared_len {
536                w.jobs[0].push((s, i * 3));
537            } else {
538                w.jobs[1].push((s - self.shared_len, i * 3));
539            }
540            // Predicate role: its own section (3).
541            w.jobs[3].push((p, i * 3 + 1));
542            // Object role: shared (section 0) if `id <= S`, else object-only
543            // (section 2) — the exact routing of `object_term`.
544            if o <= self.shared_len {
545                w.jobs[0].push((o, i * 3 + 2));
546            } else {
547                w.jobs[2].push((o - self.shared_len, i * 3 + 2));
548            }
549        }
550        for si in 0..4 {
551            self.sections[si].resolve_into(&mut w.jobs[si], &mut w.out);
552        }
553        for i in 0..ids.len() {
554            if let (Some(s), Some(p), Some(o)) =
555                (&w.out[i * 3], &w.out[i * 3 + 1], &w.out[i * 3 + 2])
556            {
557                emit(s, p, o);
558            }
559        }
560    }
561
562    /// The four serialized sections (header + body each), for the file writer.
563    pub fn sections(&self) -> [Vec<u8>; 4] {
564        [
565            self.sections[0].raw_section_bytes(),
566            self.sections[1].raw_section_bytes(),
567            self.sections[2].raw_section_bytes(),
568            self.sections[3].raw_section_bytes(),
569        ]
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    fn dict() -> Dictionary {
578        let mut b = DictionaryBuilder::new();
579        // Alice knows Bob; Bob knows Carol. So Bob is both subject and object
580        // (shared); Alice is subject-only; Carol is object-only.
581        b.observe("Alice", "knows", "Bob");
582        b.observe("Bob", "knows", "Carol");
583        b.build()
584    }
585
586    #[test]
587    fn shared_term_has_same_id_in_both_roles() {
588        let d = dict();
589        assert_eq!(d.shared_count(), 1); // just "Bob"
590        let sid = d.subject_id("Bob").unwrap();
591        let oid = d.object_id("Bob").unwrap();
592        assert_eq!(sid, oid, "shared term must share its ID across roles");
593        assert_eq!(sid, 1, "shared terms get the lowest IDs");
594    }
595
596    #[test]
597    fn role_specific_terms_round_trip() {
598        let d = dict();
599        // subject-only Alice
600        let a = d.subject_id("Alice").unwrap();
601        assert!(a > d.shared_count());
602        assert_eq!(d.subject_term(a).as_deref(), Some("Alice"));
603        // object-only Carol
604        let c = d.object_id("Carol").unwrap();
605        assert!(c > d.shared_count());
606        assert_eq!(d.object_term(c).as_deref(), Some("Carol"));
607        // predicate
608        let k = d.predicate_id("knows").unwrap();
609        assert_eq!(d.predicate_term(k).as_deref(), Some("knows"));
610        // Alice never appears as object, Carol never as subject.
611        assert_eq!(d.object_id("Alice"), None);
612        assert_eq!(d.subject_id("Carol"), None);
613    }
614
615    #[test]
616    fn encode_full_triples() {
617        let d = dict();
618        let t1 = d.encode("Alice", "knows", "Bob").unwrap();
619        let t2 = d.encode("Bob", "knows", "Carol").unwrap();
620        // Bob is object in t1 and subject in t2 — same ID both times.
621        assert_eq!(t1.2, t2.0);
622        assert!(d.encode("Nobody", "knows", "Bob").is_none());
623    }
624}