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        // An expression constraint evaluates a node expression (paths + nested
175        // shape filters); price it like a SPARQL leaf so cost-ordered planning
176        // runs cheaper structural checks first.
177        Shape::Expression(_) => C_SPARQL,
178    };
179    computing[i] = false;
180    memo[i] = Some(cost);
181    cost
182}
183
184fn path_cost(p: &Path) -> u64 {
185    match p {
186        Path::Id => 0,
187        Path::Pred(_) => 1,
188        Path::Inverse(inner) => 1 + path_cost(inner),
189        Path::Seq(ps) | Path::Alt(ps) => ps.iter().map(path_cost).sum::<u64>().max(1),
190        Path::Star(inner) => C_STAR * (1 + path_cost(inner)),
191    }
192}
193
194// ---- rendering (inspect --stage plan) ----
195
196/// Render a plan as text for `shacl inspect --stage plan`.
197pub fn plan_to_text(plan: &PhysicalPlan) -> String {
198    let mut out = String::new();
199    out.push_str(&format!("plan: {} statement(s)\n", plan.statements.len()));
200    for (i, st) in plan.statements.iter().enumerate() {
201        out.push_str(&format!(
202            "  [{i}] {}  ⇒  @{}\n",
203            focus_to_string(&st.source),
204            st.shape.0
205        ));
206    }
207
208    let costs = compute_costs(&plan.arena);
209    let reachable = reachable_shapes(plan);
210    out.push_str("shapes (cost-ordered):\n");
211    for id in &reachable {
212        out.push_str(&format!(
213            "  @{} [cost {}] = {}\n",
214            id.0,
215            costs[id.0 as usize],
216            shape_to_string(&plan.arena, *id),
217        ));
218    }
219    out
220}
221
222fn focus_to_string(source: &FocusSource) -> String {
223    match source {
224        FocusSource::SubjectsOf(p) => format!("subjectsOf({p})"),
225        FocusSource::ObjectsOf(p) => format!("objectsOf({p})"),
226        FocusSource::Node(c) => format!("node({c})"),
227        FocusSource::PathToConst { path, target } => {
228            format!("seed {target} ⟵ {}", path_to_string(path))
229        }
230        FocusSource::ScanFilter { path, qualifier } => {
231            format!("scan ∃ {} . @{}", path_to_string(path), qualifier.0)
232        }
233        FocusSource::Sparql(_) => "sparql{…}".to_string(),
234    }
235}
236
237fn reachable_shapes(plan: &PhysicalPlan) -> BTreeSet<ShapeId> {
238    let mut stack: Vec<ShapeId> = Vec::new();
239    for st in &plan.statements {
240        stack.push(st.shape);
241        if let FocusSource::ScanFilter { qualifier, .. } = &st.source {
242            stack.push(*qualifier);
243        }
244    }
245    let mut seen = BTreeSet::new();
246    while let Some(id) = stack.pop() {
247        if seen.insert(id) {
248            match plan.arena.get(id) {
249                Shape::Annotated { shape, .. } => stack.push(*shape),
250                Shape::Not(c) => stack.push(*c),
251                Shape::And(cs) | Shape::Or(cs) => stack.extend(cs.iter().copied()),
252                Shape::Count { qualifier, .. } => stack.push(*qualifier),
253                Shape::Expression(e) => e.referenced_shapes(&mut stack),
254                _ => {}
255            }
256        }
257    }
258    seen
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use shifty_algebra::{NodeKindSet, Statement};
265
266    fn nn(s: &str) -> NamedNode {
267        NamedNode::new(s).unwrap()
268    }
269
270    fn schema_with(arena: ShapeArena, selector: Selector, shape: ShapeId) -> Schema {
271        Schema {
272            arena,
273            statements: vec![Statement { selector, shape }],
274            rules: Vec::new(),
275            names: Default::default(),
276        }
277    }
278
279    #[test]
280    fn reorders_and_cheap_first() {
281        // And([expensive count over a star path, cheap nodeKind]) → cheap first
282        let mut a = ShapeArena::new();
283        let kind = a.insert(Shape::TestKind(NodeKindSet::IRI));
284        let top = a.insert(Shape::Top);
285        let star = Path::star(Path::Pred(nn("http://ex/p")));
286        let count = a.insert(Shape::Count {
287            path: star,
288            min: Some(1),
289            max: None,
290            qualifier: top,
291        });
292        let and = a.insert(Shape::And(vec![count, kind])); // expensive first
293        let p = plan(&schema_with(
294            a,
295            Selector::IsConst(Term::NamedNode(nn("http://ex/x"))),
296            and,
297        ));
298        match p.arena.get(and) {
299            Shape::And(cs) => assert_eq!(cs, &vec![kind, count]), // cheap nodeKind moved first
300            other => panic!("expected And, got {other:?}"),
301        }
302    }
303
304    #[test]
305    fn class_target_seeds_from_constant() {
306        // HasPath(rdf:type/subClassOf*, test(ex:Person)) → PathToConst seed
307        let mut a = ShapeArena::new();
308        let class = Term::NamedNode(nn("http://ex/Person"));
309        let test = a.insert(Shape::TestConst(class.clone()));
310        let path = Path::seq(vec![
311            Path::Pred(nn("http://www.w3.org/1999/02/22-rdf-syntax-ns#type")),
312            Path::star(Path::Pred(nn(
313                "http://www.w3.org/2000/01/rdf-schema#subClassOf",
314            ))),
315        ]);
316        let root = a.insert(Shape::TestKind(NodeKindSet::IRI));
317        let p = plan(&schema_with(a, Selector::HasPath(path.clone(), test), root));
318        assert_eq!(
319            p.statements[0].source,
320            FocusSource::PathToConst {
321                path,
322                target: class
323            }
324        );
325    }
326
327    #[test]
328    fn simple_selectors_compile() {
329        let mut a = ShapeArena::new();
330        let root = a.insert(Shape::Top);
331        let p = plan(&schema_with(a, Selector::HasOut(nn("http://ex/q")), root));
332        assert_eq!(
333            p.statements[0].source,
334            FocusSource::SubjectsOf(nn("http://ex/q"))
335        );
336    }
337}