Skip to main content

shifty_opt/
plan.rs

1//! Logical → physical planning (Layer 5).
2//!
3//! A [`PhysicalPlan`] decides *how* each `(selector, φ)` statement is evaluated.
4//! This first slice introduces two static (data-independent) optimizations:
5//!
6//! 1. **Selector seeding** — each selector compiles to a [`FocusSource`] that
7//!    enumerates focus nodes directly. The important case is a path selector
8//!    whose qualifier is a constant (class targets): instead of scanning every
9//!    node and testing `∃π.test(c)`, we seed *backward* from the constant
10//!    (`pred(c, π)`).
11//! 2. **Cost-based ordering** — `And`/`Or` children are reordered cheapest-first
12//!    using a static [cost model](shape_cost), so the short-circuiting evaluator
13//!    rejects/accepts on cheap atoms before walking expensive paths.
14//!
15//! Data-aware selectivity, path compilation, and the plan executor come next.
16
17use serde::{Deserialize, Serialize};
18use shifty_algebra::render::{path_to_string, shape_to_string};
19use shifty_algebra::{
20    NamedNode, Path, Schema, Selector, Shape, ShapeArena, ShapeId, SparqlTarget, Term,
21};
22use std::collections::BTreeSet;
23use std::collections::HashMap;
24
25/// How to enumerate the focus nodes of a statement.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub enum FocusSource {
28    /// `sh:targetSubjectsOf p` — subjects of `(·, p, ·)`.
29    SubjectsOf(NamedNode),
30    /// `sh:targetObjectsOf p` — objects of `(·, p, ·)`.
31    ObjectsOf(NamedNode),
32    /// `sh:targetNode c` — a single node.
33    Node(Term),
34    /// Path selector with a constant qualifier (e.g. class targets): the focus
35    /// set is `pred(target, path)` — nodes reaching `target` along `path`.
36    PathToConst { path: Path, target: Term },
37    /// General path selector: scan candidate nodes, keep those with a
38    /// path-successor satisfying `qualifier`.
39    ScanFilter { path: Path, qualifier: ShapeId },
40    /// Parsed and canonicalized SPARQL target.
41    Sparql(SparqlTarget),
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct StatementPlan {
46    pub source: FocusSource,
47    pub shape: ShapeId,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct PhysicalPlan {
52    /// The shape arena with `And`/`Or` children reordered cheapest-first.
53    pub arena: ShapeArena,
54    pub statements: Vec<StatementPlan>,
55    /// IRI names for named shape nodes, copied from the source schema for
56    /// profiling and diagnostics (same contents as `Schema::names`).
57    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
58    pub names: HashMap<ShapeId, String>,
59}
60
61/// Plan a (normalized) schema.
62pub fn plan(schema: &Schema) -> PhysicalPlan {
63    let mut arena = schema.arena.clone();
64    let costs = compute_costs(&arena);
65
66    // reorder And/Or children cheapest-first (ties by id for determinism)
67    for i in 0..arena.len() {
68        let id = ShapeId(i as u32);
69        let reordered = match arena.get(id).clone() {
70            Shape::And(cs) => Some(Shape::And(sort_by_cost(cs, &costs))),
71            Shape::Or(cs) => Some(Shape::Or(sort_by_cost(cs, &costs))),
72            _ => None,
73        };
74        if let Some(s) = reordered {
75            arena.set(id, s);
76        }
77    }
78
79    let statements = schema
80        .statements
81        .iter()
82        .map(|st| StatementPlan {
83            source: plan_selector(&arena, &st.selector),
84            shape: st.shape,
85        })
86        .collect();
87
88    arena.debug_assert_finalized();
89    PhysicalPlan {
90        arena,
91        statements,
92        names: schema.names.clone(),
93    }
94}
95
96fn sort_by_cost(mut cs: Vec<ShapeId>, costs: &[u64]) -> Vec<ShapeId> {
97    cs.sort_by_key(|c| (costs[c.0 as usize], c.0));
98    cs
99}
100
101fn plan_selector(arena: &ShapeArena, sel: &Selector) -> FocusSource {
102    match sel {
103        Selector::HasOut(p) => FocusSource::SubjectsOf(p.clone()),
104        Selector::HasIn(p) => FocusSource::ObjectsOf(p.clone()),
105        Selector::IsConst(c) => FocusSource::Node(c.clone()),
106        Selector::HasPath(path, qual) => match arena.get(*qual) {
107            Shape::TestConst(c) => FocusSource::PathToConst {
108                path: path.clone(),
109                target: c.clone(),
110            },
111            _ => FocusSource::ScanFilter {
112                path: path.clone(),
113                qualifier: *qual,
114            },
115        },
116        Selector::Sparql(target) => FocusSource::Sparql(target.clone()),
117    }
118}
119
120// ---- static cost model ----
121
122const C_CLOSED: u64 = 4;
123const C_PAIR: u64 = 2;
124const C_SPARQL: u64 = 100;
125const C_STAR: u64 = 10;
126const C_RECURSIVE: u64 = 50;
127
128/// Estimated relative cost of checking each arena node at one focus node.
129pub fn compute_costs(arena: &ShapeArena) -> Vec<u64> {
130    let mut memo = vec![None; arena.len()];
131    let mut computing = vec![false; arena.len()];
132    for i in 0..arena.len() {
133        cost_of(arena, ShapeId(i as u32), &mut memo, &mut computing);
134    }
135    memo.into_iter().map(|c| c.unwrap_or(0)).collect()
136}
137
138fn cost_of(
139    arena: &ShapeArena,
140    id: ShapeId,
141    memo: &mut [Option<u64>],
142    computing: &mut [bool],
143) -> u64 {
144    let i = id.0 as usize;
145    if let Some(c) = memo[i] {
146        return c;
147    }
148    if computing[i] {
149        return C_RECURSIVE; // back-edge in a recursive shape
150    }
151    computing[i] = true;
152    let cost = match arena.get(id).clone() {
153        Shape::Annotated { shape, .. } => cost_of(arena, shape, memo, computing),
154        Shape::Top | Shape::Pending => 0,
155        Shape::TestConst(_) | Shape::TestKind(_) | Shape::TestType(_) => 1,
156        Shape::Closed(_) => C_CLOSED,
157        Shape::Eq(p, _) | Shape::Disj(p, _) | Shape::Lt(p, _) | Shape::Le(p, _) => {
158            C_PAIR + path_cost(&p)
159        }
160        Shape::UniqueLang(p) => 1 + path_cost(&p),
161        Shape::Not(c) => cost_of(arena, c, memo, computing),
162        Shape::And(cs) | Shape::Or(cs) => cs
163            .iter()
164            .map(|c| cost_of(arena, *c, memo, computing))
165            .sum::<u64>()
166            .max(1),
167        Shape::Count {
168            path, qualifier, ..
169        } => {
170            let q = cost_of(arena, qualifier, memo, computing);
171            path_cost(&path) * (1 + q)
172        }
173        Shape::Sparql(_) => C_SPARQL,
174    };
175    computing[i] = false;
176    memo[i] = Some(cost);
177    cost
178}
179
180fn path_cost(p: &Path) -> u64 {
181    match p {
182        Path::Id => 0,
183        Path::Pred(_) => 1,
184        Path::Inverse(inner) => 1 + path_cost(inner),
185        Path::Seq(ps) | Path::Alt(ps) => ps.iter().map(path_cost).sum::<u64>().max(1),
186        Path::Star(inner) => C_STAR * (1 + path_cost(inner)),
187    }
188}
189
190// ---- rendering (inspect --stage plan) ----
191
192/// Render a plan as text for `shacl inspect --stage plan`.
193pub fn plan_to_text(plan: &PhysicalPlan) -> String {
194    let mut out = String::new();
195    out.push_str(&format!("plan: {} statement(s)\n", plan.statements.len()));
196    for (i, st) in plan.statements.iter().enumerate() {
197        out.push_str(&format!(
198            "  [{i}] {}  ⇒  @{}\n",
199            focus_to_string(&st.source),
200            st.shape.0
201        ));
202    }
203
204    let costs = compute_costs(&plan.arena);
205    let reachable = reachable_shapes(plan);
206    out.push_str("shapes (cost-ordered):\n");
207    for id in &reachable {
208        out.push_str(&format!(
209            "  @{} [cost {}] = {}\n",
210            id.0,
211            costs[id.0 as usize],
212            shape_to_string(&plan.arena, *id),
213        ));
214    }
215    out
216}
217
218fn focus_to_string(source: &FocusSource) -> String {
219    match source {
220        FocusSource::SubjectsOf(p) => format!("subjectsOf({p})"),
221        FocusSource::ObjectsOf(p) => format!("objectsOf({p})"),
222        FocusSource::Node(c) => format!("node({c})"),
223        FocusSource::PathToConst { path, target } => {
224            format!("seed {target} ⟵ {}", path_to_string(path))
225        }
226        FocusSource::ScanFilter { path, qualifier } => {
227            format!("scan ∃ {} . @{}", path_to_string(path), qualifier.0)
228        }
229        FocusSource::Sparql(_) => "sparql{…}".to_string(),
230    }
231}
232
233fn reachable_shapes(plan: &PhysicalPlan) -> BTreeSet<ShapeId> {
234    let mut stack: Vec<ShapeId> = Vec::new();
235    for st in &plan.statements {
236        stack.push(st.shape);
237        if let FocusSource::ScanFilter { qualifier, .. } = &st.source {
238            stack.push(*qualifier);
239        }
240    }
241    let mut seen = BTreeSet::new();
242    while let Some(id) = stack.pop() {
243        if seen.insert(id) {
244            match plan.arena.get(id) {
245                Shape::Annotated { shape, .. } => stack.push(*shape),
246                Shape::Not(c) => stack.push(*c),
247                Shape::And(cs) | Shape::Or(cs) => stack.extend(cs.iter().copied()),
248                Shape::Count { qualifier, .. } => stack.push(*qualifier),
249                _ => {}
250            }
251        }
252    }
253    seen
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use shifty_algebra::{NodeKindSet, Statement};
260
261    fn nn(s: &str) -> NamedNode {
262        NamedNode::new(s).unwrap()
263    }
264
265    fn schema_with(arena: ShapeArena, selector: Selector, shape: ShapeId) -> Schema {
266        Schema {
267            arena,
268            statements: vec![Statement { selector, shape }],
269            rules: Vec::new(),
270            names: Default::default(),
271        }
272    }
273
274    #[test]
275    fn reorders_and_cheap_first() {
276        // And([expensive count over a star path, cheap nodeKind]) → cheap first
277        let mut a = ShapeArena::new();
278        let kind = a.insert(Shape::TestKind(NodeKindSet::IRI));
279        let top = a.insert(Shape::Top);
280        let star = Path::star(Path::Pred(nn("http://ex/p")));
281        let count = a.insert(Shape::Count {
282            path: star,
283            min: Some(1),
284            max: None,
285            qualifier: top,
286        });
287        let and = a.insert(Shape::And(vec![count, kind])); // expensive first
288        let p = plan(&schema_with(
289            a,
290            Selector::IsConst(Term::NamedNode(nn("http://ex/x"))),
291            and,
292        ));
293        match p.arena.get(and) {
294            Shape::And(cs) => assert_eq!(cs, &vec![kind, count]), // cheap nodeKind moved first
295            other => panic!("expected And, got {other:?}"),
296        }
297    }
298
299    #[test]
300    fn class_target_seeds_from_constant() {
301        // HasPath(rdf:type/subClassOf*, test(ex:Person)) → PathToConst seed
302        let mut a = ShapeArena::new();
303        let class = Term::NamedNode(nn("http://ex/Person"));
304        let test = a.insert(Shape::TestConst(class.clone()));
305        let path = Path::seq(vec![
306            Path::Pred(nn("http://www.w3.org/1999/02/22-rdf-syntax-ns#type")),
307            Path::star(Path::Pred(nn(
308                "http://www.w3.org/2000/01/rdf-schema#subClassOf",
309            ))),
310        ]);
311        let root = a.insert(Shape::TestKind(NodeKindSet::IRI));
312        let p = plan(&schema_with(a, Selector::HasPath(path.clone(), test), root));
313        assert_eq!(
314            p.statements[0].source,
315            FocusSource::PathToConst {
316                path,
317                target: class
318            }
319        );
320    }
321
322    #[test]
323    fn simple_selectors_compile() {
324        let mut a = ShapeArena::new();
325        let root = a.insert(Shape::Top);
326        let p = plan(&schema_with(a, Selector::HasOut(nn("http://ex/q")), root));
327        assert_eq!(
328            p.statements[0].source,
329            FocusSource::SubjectsOf(nn("http://ex/q"))
330        );
331    }
332}