Skip to main content

shifty_engine/
path.rs

1//! Relational evaluation of the path algebra `⟦π⟧^G` (doc 00 §2, Table 1).
2//!
3//! Two mutually-recursive functions implement the relation by following it
4//! forward ([`succ`]) or backward ([`pred`]); `Inverse` simply swaps between
5//! them. This is the naive reference evaluation — correctness over speed.
6//!
7//! Both are generic over a [`PathBackend`]: the leaf `Path::Pred` arms dispatch
8//! through the trait's two adjacency lookups, so the same evaluator runs over a
9//! linear `oxrdf::Graph` (legacy calls) or the dictionary-encoded
10//! [`FrozenIndexedDataset`] (compiled inference and validation). Every
11//! other arm (`Id`/`Seq`/`Alt`/`Star`/`Inverse`) is pure combinator logic and is
12//! backend-agnostic.
13
14use crate::frozen::{FrozenIndexedDataset, GraphSel};
15use oxrdf::{Graph, NamedNode, NamedOrBlankNode, Term};
16use shifty_algebra::Path;
17use std::collections::{BTreeSet, HashSet};
18
19/// Storage backend for path evaluation. Exposes the three adjacency primitives
20/// `succ`/`pred`/`sh:closed` need, so path evaluation can dispatch to either a
21/// linear `oxrdf::Graph` or the indexed [`FrozenIndexedDataset`].
22pub trait PathBackend {
23    /// `{ o | (subject, predicate, o) ∈ G }` — forward step.
24    fn objects(&self, subject: &Term, predicate: &NamedNode) -> HashSet<Term>;
25    /// `{ s | (s, predicate, object) ∈ G }` — backward step.
26    fn subjects(&self, predicate: &NamedNode, object: &Term) -> HashSet<Term>;
27    /// Predicates of the outgoing triples of `subject` (used by `sh:closed`).
28    fn out_predicates(&self, subject: &Term) -> BTreeSet<NamedNode>;
29
30    /// Whether `(subject, predicate, object)` is in the evaluation graph.
31    ///
32    /// Witnessing asks this once per candidate value to certify the edge that
33    /// produced it. The default answers it by materializing the whole successor
34    /// set, which makes a value-set of size `n` cost `O(n²)`; backends with an
35    /// index should override it.
36    fn contains(&self, subject: &Term, predicate: &NamedNode, object: &Term) -> bool {
37        self.objects(subject, predicate).contains(object)
38    }
39}
40
41/// `{ u | (node, u) ∈ ⟦path⟧ }` — nodes reachable forward from `node`.
42pub fn succ<B: PathBackend + ?Sized>(g: &B, node: &Term, path: &Path) -> HashSet<Term> {
43    match path {
44        Path::Id => once(node.clone()),
45        Path::Pred(q) => g.objects(node, q),
46        Path::Inverse(p) => pred(g, node, p),
47        Path::Seq(ps) => fold_seq(g, node, ps, succ),
48        Path::Alt(ps) => ps.iter().flat_map(|p| succ(g, node, p)).collect(),
49        Path::Star(p) => closure(g, node, |g, n| succ(g, n, p)),
50    }
51}
52
53/// `{ u | (u, node) ∈ ⟦path⟧ }` — nodes that reach `node`.
54pub fn pred<B: PathBackend + ?Sized>(g: &B, node: &Term, path: &Path) -> HashSet<Term> {
55    match path {
56        Path::Id => once(node.clone()),
57        Path::Pred(q) => g.subjects(q, node),
58        Path::Inverse(p) => succ(g, node, p),
59        // (u, node) ∈ ⟦p1·…·pk⟧ : peel predicates from the right.
60        Path::Seq(ps) => {
61            let mut cursor = once(node.clone());
62            for p in ps.iter().rev() {
63                cursor = cursor.iter().flat_map(|x| pred(g, x, p)).collect();
64            }
65            cursor
66        }
67        Path::Alt(ps) => ps.iter().flat_map(|p| pred(g, node, p)).collect(),
68        Path::Star(p) => closure(g, node, |g, n| pred(g, n, p)),
69    }
70}
71
72fn fold_seq<B: PathBackend + ?Sized>(
73    g: &B,
74    node: &Term,
75    ps: &[Path],
76    step: fn(&B, &Term, &Path) -> HashSet<Term>,
77) -> HashSet<Term> {
78    let mut cursor = once(node.clone());
79    for p in ps {
80        cursor = cursor.iter().flat_map(|x| step(g, x, p)).collect();
81    }
82    cursor
83}
84
85/// Reflexive-transitive closure of a one-step function (for `π*`).
86fn closure<B, F>(g: &B, node: &Term, step: F) -> HashSet<Term>
87where
88    B: PathBackend + ?Sized,
89    F: Fn(&B, &Term) -> HashSet<Term>,
90{
91    let mut result = once(node.clone());
92    let mut frontier = vec![node.clone()];
93    while let Some(x) = frontier.pop() {
94        for y in step(g, &x) {
95            if result.insert(y.clone()) {
96                frontier.push(y);
97            }
98        }
99    }
100    result
101}
102
103fn once(t: Term) -> HashSet<Term> {
104    let mut s = HashSet::with_capacity(1);
105    s.insert(t);
106    s
107}
108
109// ── Backends ─────────────────────────────────────────────────────────────────
110
111/// Linear backend over oxrdf's B-tree indexes. Legacy inference retains a
112/// mutable graph context; compiled sessions use the dataset backend below.
113impl PathBackend for Graph {
114    fn objects(&self, subject: &Term, predicate: &NamedNode) -> HashSet<Term> {
115        match node_of(subject) {
116            Some(s) => self
117                .objects_for_subject_predicate(&s, predicate.as_ref())
118                .map(|t| t.into_owned())
119                .collect(),
120            None => HashSet::new(),
121        }
122    }
123
124    fn subjects(&self, predicate: &NamedNode, object: &Term) -> HashSet<Term> {
125        self.subjects_for_predicate_object(predicate.as_ref(), object)
126            .map(|s| term_of(s.into_owned()))
127            .collect()
128    }
129
130    fn out_predicates(&self, subject: &Term) -> BTreeSet<NamedNode> {
131        let mut preds = BTreeSet::new();
132        if let Some(n) = node_of(subject) {
133            for t in self.triples_for_subject(&n) {
134                preds.insert(t.predicate.into_owned());
135            }
136        }
137        preds
138    }
139
140    fn contains(&self, subject: &Term, predicate: &NamedNode, object: &Term) -> bool {
141        node_of(subject).is_some_and(|s| {
142            Graph::contains(
143                self,
144                oxrdf::TripleRef::new(s.as_ref(), predicate.as_ref(), object.as_ref()),
145            )
146        })
147    }
148}
149
150/// Indexed backend over the dictionary-encoded post-inference snapshot. Built
151/// once at the inference→validation boundary and shared across focus nodes; the
152/// `u32`-keyed sorted indexes replace per-call term hashing and B-tree walks.
153/// Unknown terms intern to fresh ids that match no stored triple — exactly the
154/// empty-result semantics path evaluation needs.
155impl PathBackend for FrozenIndexedDataset {
156    fn objects(&self, subject: &Term, predicate: &NamedNode) -> HashSet<Term> {
157        let s = self.intern(subject);
158        let p = self.intern(&Term::NamedNode(predicate.clone()));
159        self.scan(Some(s), Some(p), None, GraphSel::Default)
160            .map(|t| self.externalize_id(t[2]))
161            .collect()
162    }
163
164    fn subjects(&self, predicate: &NamedNode, object: &Term) -> HashSet<Term> {
165        let p = self.intern(&Term::NamedNode(predicate.clone()));
166        let o = self.intern(object);
167        self.scan(None, Some(p), Some(o), GraphSel::Default)
168            .map(|t| self.externalize_id(t[0]))
169            .collect()
170    }
171
172    fn out_predicates(&self, subject: &Term) -> BTreeSet<NamedNode> {
173        let s = self.intern(subject);
174        self.scan(Some(s), None, None, GraphSel::Default)
175            .filter_map(|t| match self.externalize_id(t[1]) {
176                Term::NamedNode(p) => Some(p),
177                _ => None,
178            })
179            .collect()
180    }
181
182    fn contains(&self, subject: &Term, predicate: &NamedNode, object: &Term) -> bool {
183        let s = self.intern(subject);
184        let p = self.intern(&Term::NamedNode(predicate.clone()));
185        let o = self.intern(object);
186        self.scan(Some(s), Some(p), Some(o), GraphSel::Default)
187            .next()
188            .is_some()
189    }
190}
191
192/// A term usable in subject position, if it is not a literal.
193pub fn node_of(term: &Term) -> Option<NamedOrBlankNode> {
194    match term {
195        Term::NamedNode(n) => Some(NamedOrBlankNode::NamedNode(n.clone())),
196        Term::BlankNode(b) => Some(NamedOrBlankNode::BlankNode(b.clone())),
197        Term::Literal(_) => None,
198    }
199}
200
201pub fn term_of(node: NamedOrBlankNode) -> Term {
202    match node {
203        NamedOrBlankNode::NamedNode(n) => Term::NamedNode(n),
204        NamedOrBlankNode::BlankNode(b) => Term::BlankNode(b),
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use oxrdf::{NamedNode, Triple};
212
213    fn nn(iri: &str) -> NamedNode {
214        NamedNode::new(iri).unwrap()
215    }
216
217    fn t(s: &str, p: &str, o: &str) -> Triple {
218        Triple::new(nn(s), nn(p), nn(o))
219    }
220
221    fn term(iri: &str) -> Term {
222        Term::NamedNode(nn(iri))
223    }
224
225    /// `a -p-> b -p-> c -q-> d`, with a side branch `a -p-> e`.
226    fn sample_graph() -> Graph {
227        let mut g = Graph::new();
228        for tr in [
229            t("http://ex/a", "http://ex/p", "http://ex/b"),
230            t("http://ex/a", "http://ex/p", "http://ex/e"),
231            t("http://ex/b", "http://ex/p", "http://ex/c"),
232            t("http://ex/c", "http://ex/q", "http://ex/d"),
233        ] {
234            g.insert(tr.as_ref());
235        }
236        g
237    }
238
239    fn p(iri: &str) -> Path {
240        Path::Pred(nn(iri))
241    }
242
243    /// The path shapes exercised by the Graph-vs-Frozen differential below.
244    fn sample_paths() -> Vec<Path> {
245        let pp = || p("http://ex/p");
246        let qq = || p("http://ex/q");
247        vec![
248            Path::Id,
249            pp(),
250            Path::Inverse(Box::new(pp())),
251            Path::Seq(vec![pp(), pp()]),
252            Path::Seq(vec![pp(), qq()]),
253            Path::Alt(vec![pp(), qq()]),
254            Path::Star(Box::new(pp())),
255            Path::Seq(vec![Path::Star(Box::new(pp())), qq()]),
256            Path::Inverse(Box::new(Path::Seq(vec![pp(), pp()]))),
257        ]
258    }
259
260    #[test]
261    fn graph_and_frozen_backends_agree() {
262        let g = sample_graph();
263        let frozen = FrozenIndexedDataset::from_graph(&g);
264        let nodes = ["http://ex/a", "http://ex/b", "http://ex/c", "http://ex/d"];
265        for path in sample_paths() {
266            for node in nodes {
267                let n = term(node);
268                assert_eq!(
269                    succ(&g, &n, &path),
270                    succ(&frozen, &n, &path),
271                    "succ mismatch at {node} for {path:?}"
272                );
273                assert_eq!(
274                    pred(&g, &n, &path),
275                    pred(&frozen, &n, &path),
276                    "pred mismatch at {node} for {path:?}"
277                );
278            }
279        }
280    }
281
282    #[test]
283    fn frozen_succ_pred_basics() {
284        let g = sample_graph();
285        let frozen = FrozenIndexedDataset::from_graph(&g);
286        let a = term("http://ex/a");
287        assert_eq!(
288            succ(&frozen, &a, &p("http://ex/p")),
289            HashSet::from([term("http://ex/b"), term("http://ex/e")])
290        );
291        let c = term("http://ex/c");
292        assert_eq!(
293            pred(&frozen, &c, &p("http://ex/p")),
294            HashSet::from([term("http://ex/b")])
295        );
296    }
297
298    #[test]
299    fn star_is_reflexive_transitive_on_both_backends() {
300        let g = sample_graph();
301        let frozen = FrozenIndexedDataset::from_graph(&g);
302        let a = term("http://ex/a");
303        let star = Path::Star(Box::new(p("http://ex/p")));
304        let expected = HashSet::from([
305            term("http://ex/a"),
306            term("http://ex/b"),
307            term("http://ex/e"),
308            term("http://ex/c"),
309        ]);
310        assert_eq!(succ(&g, &a, &star), expected);
311        assert_eq!(succ(&frozen, &a, &star), expected);
312    }
313
314    #[test]
315    fn out_predicates_agree() {
316        let g = sample_graph();
317        let frozen = FrozenIndexedDataset::from_graph(&g);
318        let a = term("http://ex/a");
319        assert_eq!(g.out_predicates(&a), BTreeSet::from([nn("http://ex/p")]));
320        assert_eq!(
321            frozen.out_predicates(&a),
322            BTreeSet::from([nn("http://ex/p")])
323        );
324        // Literal subject → no outgoing predicates on either backend.
325        let lit = Term::Literal(oxrdf::Literal::new_simple_literal("x"));
326        assert!(g.out_predicates(&lit).is_empty());
327        assert!(frozen.out_predicates(&lit).is_empty());
328    }
329}