Skip to main content

tensorlogic_oxirs_bridge/
interned_graph.rs

1//! Intern-based term dictionary and triple store for the tensor SPARQL evaluator.
2//!
3//! [`InternedGraph`] maps every RDF term (IRI or literal string) to a compact `u32`
4//! identifier and stores triples as `(subject_id, predicate_id, object_id)` tuples.
5//! Lookups are O(1) via two complementary indexes:
6//! - `dict`: term string → id
7//! - `by_predicate`: predicate_id → list of (subject_id, object_id) pairs
8//!
9//! # Parallel bulk load
10//!
11//! When the input contains ≤1_000_000 triples the constructor spawns one thread
12//! per CPU (using `std::thread::scope`) to parse chunks in parallel.  Each thread
13//! builds a local `HashMap<&str, ()>` of unique terms; the main thread then merges
14//! all unique strings into one global dictionary before filling the adjacency
15//! indexes in a single sequential pass.  For inputs larger than the threshold the
16//! whole process is sequential.
17//!
18//! In `#[cfg(test)]` the threshold is overridden to 4 so that even small inputs
19//! exercise the parallel path.
20
21use std::collections::HashMap;
22use std::collections::HashSet;
23
24use crate::quad_store::QuadStore;
25use crate::rdf_bulk_io::{BulkIoError, RdfBulkImporter, RdfTriple};
26use crate::schema::nquads::Quad;
27
28// ─── threshold ───────────────────────────────────────────────────────────────
29
30#[cfg(not(test))]
31const PARALLEL_THRESHOLD: usize = 1_000_000;
32
33/// Override for tests: use parallel path even for tiny inputs.
34#[cfg(test)]
35const PARALLEL_THRESHOLD: usize = 4;
36
37// ─── InternedGraph ────────────────────────────────────────────────────────────
38
39/// Term dictionary + triple store backed by interned `u32` IDs.
40pub struct InternedGraph {
41    /// term string → compact u32 ID
42    dict: HashMap<String, u32>,
43    /// compact u32 ID → term string (reverse lookup)
44    terms: Vec<String>,
45    /// All triples as (subject_id, predicate_id, object_id)
46    pub(crate) triples: Vec<(u32, u32, u32)>,
47    /// Predicate-anchored index: pred_id → [(subj_id, obj_id)]
48    by_predicate: HashMap<u32, Vec<(u32, u32)>>,
49}
50
51impl InternedGraph {
52    /// Create an empty graph.
53    pub fn new() -> Self {
54        Self {
55            dict: HashMap::new(),
56            terms: Vec::new(),
57            triples: Vec::new(),
58            by_predicate: HashMap::new(),
59        }
60    }
61
62    /// Intern `term`: return existing ID if present, otherwise allocate a new one.
63    pub fn intern(&mut self, term: &str) -> u32 {
64        if let Some(&id) = self.dict.get(term) {
65            return id;
66        }
67        let id = self.dict.len() as u32;
68        self.dict.insert(term.to_string(), id);
69        self.terms.push(term.to_string());
70        id
71    }
72
73    /// Intern without inserting.  Returns `None` when the term is unknown.
74    pub fn intern_or_none(&self, term: &str) -> Option<u32> {
75        self.dict.get(term).copied()
76    }
77
78    /// Resolve an ID back to the original term string.
79    pub fn term(&self, id: u32) -> Option<&str> {
80        self.terms.get(id as usize).map(String::as_str)
81    }
82
83    /// Add a triple, interning each component as needed.
84    pub fn add_triple(&mut self, s: &str, p: &str, o: &str) {
85        let s_id = self.intern(s);
86        let p_id = self.intern(p);
87        let o_id = self.intern(o);
88        self.triples.push((s_id, p_id, o_id));
89        self.by_predicate
90            .entry(p_id)
91            .or_default()
92            .push((s_id, o_id));
93    }
94
95    /// Return all `(subject_id, object_id)` pairs for the given predicate ID.
96    /// Returns an empty slice when the predicate has no triples.
97    pub fn predicate_pairs(&self, pred_id: u32) -> &[(u32, u32)] {
98        match self.by_predicate.get(&pred_id) {
99            Some(pairs) => pairs.as_slice(),
100            None => &[],
101        }
102    }
103
104    /// Number of distinct terms in the dictionary.
105    pub fn num_entities(&self) -> usize {
106        self.dict.len()
107    }
108
109    /// Total number of triples stored.
110    pub fn num_triples(&self) -> usize {
111        self.triples.len()
112    }
113
114    /// Build an [`InternedGraph`] from a collection of [`RdfTriple`]s.
115    ///
116    /// Uses parallel term-collection when `triples.len() <= PARALLEL_THRESHOLD`,
117    /// single-threaded otherwise.
118    pub fn from_rdf_triples(triples: Vec<RdfTriple>) -> Self {
119        if triples.is_empty() {
120            return Self::new();
121        }
122
123        let num_triples = triples.len();
124        let use_parallel = num_triples <= PARALLEL_THRESHOLD;
125
126        if use_parallel {
127            Self::from_rdf_triples_parallel(triples)
128        } else {
129            Self::from_rdf_triples_sequential(triples)
130        }
131    }
132
133    // ── Sequential (fallback) ─────────────────────────────────────────────────
134
135    fn from_rdf_triples_sequential(triples: Vec<RdfTriple>) -> Self {
136        let mut g = Self::new();
137        for t in &triples {
138            g.add_triple(&t.subject, &t.predicate, &t.object);
139        }
140        g
141    }
142
143    // ── Parallel ──────────────────────────────────────────────────────────────
144
145    /// Parallel build strategy:
146    /// 1. Split input into chunks (one per logical CPU, capped at 8).
147    /// 2. Each thread collects the set of unique term strings from its chunk.
148    /// 3. Main thread merges all unique strings → builds global dict.
149    /// 4. Sequential pass over all triples to resolve IDs and fill indexes.
150    fn from_rdf_triples_parallel(triples: Vec<RdfTriple>) -> Self {
151        let raw_cpus = std::thread::available_parallelism()
152            .map(|n| n.get())
153            .unwrap_or(2);
154        let num_cpus = raw_cpus.clamp(1, 8);
155        let num_chunks = num_cpus.min(triples.len()).max(1);
156
157        eprintln!(
158            "[InternedGraph] parallel bulk-load: {} triples, {} chunks",
159            triples.len(),
160            num_chunks
161        );
162
163        let chunk_size = triples.len().div_ceil(num_chunks);
164        let chunks: Vec<&[RdfTriple]> = triples.chunks(chunk_size).collect();
165
166        // Phase 1: parallel term collection — gather unique strings per chunk
167        let mut per_chunk_terms: Vec<HashSet<String>> =
168            (0..chunks.len()).map(|_| HashSet::new()).collect();
169
170        std::thread::scope(|scope| {
171            let mut handles = Vec::with_capacity(chunks.len());
172            for chunk in &chunks {
173                let handle = scope.spawn(|| {
174                    let mut local: HashSet<String> = HashSet::new();
175                    for t in chunk.iter() {
176                        local.insert(t.subject.clone());
177                        local.insert(t.predicate.clone());
178                        local.insert(t.object.clone());
179                    }
180                    local
181                });
182                handles.push(handle);
183            }
184            for (i, handle) in handles.into_iter().enumerate() {
185                // Scope threads are guaranteed to finish before scope exits,
186                // so joining them here is always valid.
187                per_chunk_terms[i] = handle.join().unwrap_or_default();
188            }
189        });
190
191        // Phase 2: merge per-chunk term sets into a sorted global list
192        // (sorting is deterministic regardless of HashMap iteration order)
193        let mut all_terms: HashSet<String> = HashSet::new();
194        for chunk_terms in per_chunk_terms {
195            all_terms.extend(chunk_terms);
196        }
197        let mut sorted_terms: Vec<String> = all_terms.into_iter().collect();
198        sorted_terms.sort_unstable();
199
200        // Phase 3: build dict/terms from sorted list
201        let mut dict: HashMap<String, u32> = HashMap::with_capacity(sorted_terms.len());
202        let mut terms_vec: Vec<String> = Vec::with_capacity(sorted_terms.len());
203        for (idx, term) in sorted_terms.into_iter().enumerate() {
204            dict.insert(term.clone(), idx as u32);
205            terms_vec.push(term);
206        }
207
208        // Phase 4: single-pass to build adjacency using resolved IDs
209        let num_triples = triples.len();
210        let mut stored_triples: Vec<(u32, u32, u32)> = Vec::with_capacity(num_triples);
211        let mut by_predicate: HashMap<u32, Vec<(u32, u32)>> = HashMap::new();
212
213        for t in &triples {
214            // SAFETY: all terms were collected in phase 1; every lookup succeeds.
215            let s_id = *dict.get(t.subject.as_str()).unwrap_or(&0);
216            let p_id = *dict.get(t.predicate.as_str()).unwrap_or(&0);
217            let o_id = *dict.get(t.object.as_str()).unwrap_or(&0);
218            stored_triples.push((s_id, p_id, o_id));
219            by_predicate.entry(p_id).or_default().push((s_id, o_id));
220        }
221
222        Self {
223            dict,
224            terms: terms_vec,
225            triples: stored_triples,
226            by_predicate,
227        }
228    }
229
230    // ── Conversion helpers ────────────────────────────────────────────────────
231
232    /// Emit a [`QuadStore`] (default graph only) containing all stored triples.
233    pub fn into_quad_store(&self) -> QuadStore {
234        let mut qs = QuadStore::new();
235        for (s_id, p_id, o_id) in &self.triples {
236            let s = self.terms[*s_id as usize].clone();
237            let p = self.terms[*p_id as usize].clone();
238            let o = self.terms[*o_id as usize].clone();
239            qs.insert_quad(Quad {
240                subject: s,
241                predicate: p,
242                object: o,
243                graph: None,
244            });
245        }
246        qs
247    }
248}
249
250impl Default for InternedGraph {
251    fn default() -> Self {
252        Self::new()
253    }
254}
255
256// ─── Free function ────────────────────────────────────────────────────────────
257
258/// Parse `input` with `importer` (auto-detecting format) and return an
259/// [`InternedGraph`].
260pub fn rdf_bulk_importer_into_interned(
261    importer: &RdfBulkImporter,
262    input: &str,
263) -> Result<InternedGraph, BulkIoError> {
264    let (triples, _stats) = importer.parse_auto(input)?;
265    Ok(InternedGraph::from_rdf_triples(triples))
266}
267
268// ─────────────────────────────────────────────────────────────────────────────
269// Tests
270// ─────────────────────────────────────────────────────────────────────────────
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    // ── intern / term round-trip ──────────────────────────────────────────────
277
278    #[test]
279    fn test_intern_idempotent() {
280        let mut g = InternedGraph::new();
281        let id1 = g.intern("Alice");
282        let id2 = g.intern("Alice");
283        assert_eq!(id1, id2, "intern must be idempotent");
284        let id3 = g.intern("Bob");
285        assert_ne!(id1, id3, "different terms must have different IDs");
286    }
287
288    #[test]
289    fn test_term_round_trip() {
290        let mut g = InternedGraph::new();
291        let id = g.intern("http://example.org/Alice");
292        assert_eq!(g.term(id), Some("http://example.org/Alice"));
293    }
294
295    #[test]
296    fn test_intern_or_none_known_and_unknown() {
297        let mut g = InternedGraph::new();
298        g.intern("known");
299        assert!(g.intern_or_none("known").is_some());
300        assert!(g.intern_or_none("unknown").is_none());
301    }
302
303    // ── add_triple / predicate_pairs ──────────────────────────────────────────
304
305    #[test]
306    fn test_add_triple_and_predicate_pairs() {
307        let mut g = InternedGraph::new();
308        g.add_triple("Alice", "knows", "Bob");
309        g.add_triple("Alice", "knows", "Carol");
310        g.add_triple("Bob", "age", "30");
311
312        let knows_id = g.intern_or_none("knows").expect("knows must be interned");
313        // Collect pairs into a Vec to release the borrow before calling intern()
314        let pairs: Vec<(u32, u32)> = g.predicate_pairs(knows_id).to_vec();
315        assert_eq!(pairs.len(), 2, "should have 2 knows pairs");
316
317        let alice_id = g.intern("Alice");
318        let bob_id = g.intern("Bob");
319        let carol_id = g.intern("Carol");
320        assert!(
321            pairs.contains(&(alice_id, bob_id)),
322            "Alice knows Bob should be present"
323        );
324        assert!(
325            pairs.contains(&(alice_id, carol_id)),
326            "Alice knows Carol should be present"
327        );
328    }
329
330    #[test]
331    fn test_predicate_pairs_absent_returns_empty() {
332        let g = InternedGraph::new();
333        assert_eq!(g.predicate_pairs(99), &[]);
334    }
335
336    // ── num_entities / num_triples ────────────────────────────────────────────
337
338    #[test]
339    fn test_num_entities_and_triples() {
340        let mut g = InternedGraph::new();
341        assert_eq!(g.num_entities(), 0);
342        assert_eq!(g.num_triples(), 0);
343
344        g.add_triple("A", "p", "B");
345        // 3 unique terms: A, p, B
346        assert_eq!(g.num_entities(), 3);
347        assert_eq!(g.num_triples(), 1);
348
349        // Adding same terms again should not create new entities
350        g.add_triple("A", "p", "B");
351        assert_eq!(g.num_entities(), 3);
352        assert_eq!(g.num_triples(), 2);
353    }
354
355    // ── from_rdf_triples (sequential vs parallel) ─────────────────────────────
356
357    #[test]
358    fn test_from_rdf_triples_bulk() {
359        let triples = vec![
360            RdfTriple::new("Alice", "knows", "Bob"),
361            RdfTriple::new("Bob", "knows", "Carol"),
362            RdfTriple::new("Carol", "age", "25"),
363        ];
364        let g = InternedGraph::from_rdf_triples(triples);
365        assert_eq!(g.num_triples(), 3);
366        // terms: Alice, knows, Bob, Carol, age, 25 → 6 unique
367        assert_eq!(g.num_entities(), 6);
368    }
369
370    #[test]
371    fn test_parallel_equivalent_to_sequential() {
372        // With PARALLEL_THRESHOLD = 4 (test override), inputs of len <= 4
373        // take the parallel path.
374        let triples: Vec<RdfTriple> = vec![
375            RdfTriple::new("s1", "p", "o1"),
376            RdfTriple::new("s2", "p", "o2"),
377            RdfTriple::new("s3", "p", "o3"),
378        ];
379
380        let parallel = InternedGraph::from_rdf_triples(triples.clone());
381        let sequential = InternedGraph::from_rdf_triples_sequential(triples);
382
383        assert_eq!(parallel.num_triples(), sequential.num_triples());
384        assert_eq!(parallel.num_entities(), sequential.num_entities());
385
386        let p_id = parallel.intern_or_none("p").expect("p must be interned");
387        let pairs_p = parallel.predicate_pairs(p_id);
388        assert_eq!(pairs_p.len(), 3);
389
390        let q_id = sequential.intern_or_none("p").expect("p must be interned");
391        let pairs_q = sequential.predicate_pairs(q_id);
392        assert_eq!(pairs_q.len(), 3);
393    }
394
395    // ── into_quad_store ───────────────────────────────────────────────────────
396
397    #[test]
398    fn test_into_quad_store_contains_correct_triples() {
399        let triples = vec![
400            RdfTriple::new("Alice", "knows", "Bob"),
401            RdfTriple::new("Bob", "knows", "Carol"),
402        ];
403        let g = InternedGraph::from_rdf_triples(triples);
404        let qs = g.into_quad_store();
405        // default graph should have 2 triples
406        assert_eq!(qs.total_quads(), 2);
407        let pairs = qs.query_predicate(None, "knows");
408        assert_eq!(pairs.len(), 2);
409    }
410
411    // ── rdf_bulk_importer_into_interned ───────────────────────────────────────
412
413    #[test]
414    fn test_bulk_importer_into_interned_ntriples() {
415        let input = "<Alice> <knows> <Bob> .\n<Bob> <knows> <Carol> .\n";
416        let importer = RdfBulkImporter::new();
417        let g =
418            rdf_bulk_importer_into_interned(&importer, input).expect("bulk import should succeed");
419        assert_eq!(g.num_triples(), 2);
420    }
421}