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    plan_with_flags(schema, true, true)
64}
65
66/// Like [`plan`] but always emits `ScanFilter` instead of `PathToConst` for
67/// class-like selectors. Used in ablation experiments to isolate the seeding
68/// optimization from all other planning work.
69pub fn plan_no_seeding(schema: &Schema) -> PhysicalPlan {
70    plan_with_flags(schema, false, true)
71}
72
73/// Like [`plan`] but skips cost-based reordering of `And`/`Or` children.
74/// Used in ablation experiments to isolate the short-circuit ordering
75/// optimization from all other planning work.
76pub fn plan_no_sort(schema: &Schema) -> PhysicalPlan {
77    plan_with_flags(schema, true, false)
78}
79
80fn plan_with_flags(schema: &Schema, seeding: bool, sort: bool) -> PhysicalPlan {
81    let mut arena = schema.arena.clone();
82
83    if sort {
84        let costs = compute_costs(&arena);
85        for i in 0..arena.len() {
86            let id = ShapeId(i as u32);
87            let reordered = match arena.get(id).clone() {
88                Shape::And(cs) => Some(Shape::And(sort_by_cost(cs, &costs))),
89                Shape::Or(cs) => Some(Shape::Or(sort_by_cost(cs, &costs))),
90                _ => None,
91            };
92            if let Some(s) = reordered {
93                arena.set(id, s);
94            }
95        }
96    }
97
98    let statements = schema
99        .statements
100        .iter()
101        .map(|st| StatementPlan {
102            source: if seeding {
103                plan_selector(&arena, &st.selector)
104            } else {
105                plan_selector_no_seeding(&arena, &st.selector)
106            },
107            shape: st.shape,
108        })
109        .collect();
110
111    arena.debug_assert_finalized();
112    PhysicalPlan {
113        arena,
114        statements,
115        names: schema.names.clone(),
116    }
117}
118
119fn sort_by_cost(mut cs: Vec<ShapeId>, costs: &[u64]) -> Vec<ShapeId> {
120    cs.sort_by_key(|c| (costs[c.0 as usize], c.0));
121    cs
122}
123
124fn plan_selector(arena: &ShapeArena, sel: &Selector) -> FocusSource {
125    match sel {
126        Selector::HasOut(p) => FocusSource::SubjectsOf(p.clone()),
127        Selector::HasIn(p) => FocusSource::ObjectsOf(p.clone()),
128        Selector::IsConst(c) => FocusSource::Node(c.clone()),
129        Selector::HasPath(path, qual) => match arena.get(*qual) {
130            Shape::TestConst(c) => FocusSource::PathToConst {
131                path: path.clone(),
132                target: c.clone(),
133            },
134            _ => FocusSource::ScanFilter {
135                path: path.clone(),
136                qualifier: *qual,
137            },
138        },
139        Selector::Sparql(target) => FocusSource::Sparql(target.clone()),
140    }
141}
142
143/// Ablation variant: always emits `ScanFilter` for `HasPath` selectors,
144/// even when the qualifier is a constant (class target). This forces the
145/// engine to scan all nodes and filter, rather than seeding backward from
146/// the constant.
147fn plan_selector_no_seeding(_arena: &ShapeArena, sel: &Selector) -> FocusSource {
148    match sel {
149        Selector::HasOut(p) => FocusSource::SubjectsOf(p.clone()),
150        Selector::HasIn(p) => FocusSource::ObjectsOf(p.clone()),
151        Selector::IsConst(c) => FocusSource::Node(c.clone()),
152        Selector::HasPath(path, qual) => FocusSource::ScanFilter {
153            path: path.clone(),
154            qualifier: *qual,
155        },
156        Selector::Sparql(target) => FocusSource::Sparql(target.clone()),
157    }
158}
159
160// ---- static cost model ----
161
162const C_CLOSED: u64 = 4;
163const C_PAIR: u64 = 2;
164const C_SPARQL: u64 = 100;
165const C_STAR: u64 = 10;
166const C_RECURSIVE: u64 = 50;
167
168/// Estimated relative cost of checking each arena node at one focus node.
169pub fn compute_costs(arena: &ShapeArena) -> Vec<u64> {
170    let mut memo = vec![None; arena.len()];
171    let mut computing = vec![false; arena.len()];
172    for i in 0..arena.len() {
173        cost_of(arena, ShapeId(i as u32), &mut memo, &mut computing);
174    }
175    memo.into_iter().map(|c| c.unwrap_or(0)).collect()
176}
177
178fn cost_of(
179    arena: &ShapeArena,
180    id: ShapeId,
181    memo: &mut [Option<u64>],
182    computing: &mut [bool],
183) -> u64 {
184    let i = id.0 as usize;
185    if let Some(c) = memo[i] {
186        return c;
187    }
188    if computing[i] {
189        return C_RECURSIVE; // back-edge in a recursive shape
190    }
191    computing[i] = true;
192    let cost = match arena.get(id).clone() {
193        Shape::Annotated { shape, .. } => cost_of(arena, shape, memo, computing),
194        Shape::Top | Shape::Pending => 0,
195        Shape::TestConst(_) | Shape::TestKind(_) | Shape::TestType(_) => 1,
196        Shape::Closed(_) => C_CLOSED,
197        Shape::Eq(p, _) | Shape::Disj(p, _) | Shape::Lt(p, _) | Shape::Le(p, _) => {
198            C_PAIR + path_cost(&p)
199        }
200        Shape::UniqueLang(p) => 1 + path_cost(&p),
201        Shape::Not(c) => cost_of(arena, c, memo, computing),
202        Shape::And(cs) | Shape::Or(cs) => cs
203            .iter()
204            .map(|c| cost_of(arena, *c, memo, computing))
205            .sum::<u64>()
206            .max(1),
207        Shape::Count {
208            path, qualifier, ..
209        } => {
210            let q = cost_of(arena, qualifier, memo, computing);
211            path_cost(&path) * (1 + q)
212        }
213        Shape::Sparql(_) => C_SPARQL,
214        // An expression constraint evaluates a node expression (paths + nested
215        // shape filters); price it like a SPARQL leaf so cost-ordered planning
216        // runs cheaper structural checks first.
217        Shape::Expression(_) => C_SPARQL,
218    };
219    computing[i] = false;
220    memo[i] = Some(cost);
221    cost
222}
223
224fn path_cost(p: &Path) -> u64 {
225    match p {
226        Path::Id => 0,
227        Path::Pred(_) => 1,
228        Path::Inverse(inner) => 1 + path_cost(inner),
229        Path::Seq(ps) | Path::Alt(ps) => ps.iter().map(path_cost).sum::<u64>().max(1),
230        Path::Star(inner) => C_STAR * (1 + path_cost(inner)),
231    }
232}
233
234// ---- rendering (inspect --stage plan) ----
235
236/// Render a plan as text for `shacl inspect --stage plan`.
237pub fn plan_to_text(plan: &PhysicalPlan) -> String {
238    let mut out = String::new();
239    out.push_str(&format!("plan: {} statement(s)\n", plan.statements.len()));
240    for (i, st) in plan.statements.iter().enumerate() {
241        out.push_str(&format!(
242            "  [{i}] {}  ⇒  @{}\n",
243            focus_to_string(&st.source),
244            st.shape.0
245        ));
246    }
247
248    let costs = compute_costs(&plan.arena);
249    let reachable = reachable_shapes(plan);
250    out.push_str("shapes (cost-ordered):\n");
251    for id in &reachable {
252        out.push_str(&format!(
253            "  @{} [cost {}] = {}\n",
254            id.0,
255            costs[id.0 as usize],
256            shape_to_string(&plan.arena, *id),
257        ));
258    }
259    out
260}
261
262fn focus_to_string(source: &FocusSource) -> String {
263    match source {
264        FocusSource::SubjectsOf(p) => format!("subjectsOf({p})"),
265        FocusSource::ObjectsOf(p) => format!("objectsOf({p})"),
266        FocusSource::Node(c) => format!("node({c})"),
267        FocusSource::PathToConst { path, target } => {
268            format!("seed {target} ⟵ {}", path_to_string(path))
269        }
270        FocusSource::ScanFilter { path, qualifier } => {
271            format!("scan ∃ {} . @{}", path_to_string(path), qualifier.0)
272        }
273        FocusSource::Sparql(_) => "sparql{…}".to_string(),
274    }
275}
276
277fn reachable_shapes(plan: &PhysicalPlan) -> BTreeSet<ShapeId> {
278    let mut stack: Vec<ShapeId> = Vec::new();
279    for st in &plan.statements {
280        stack.push(st.shape);
281        if let FocusSource::ScanFilter { qualifier, .. } = &st.source {
282            stack.push(*qualifier);
283        }
284    }
285    let mut seen = BTreeSet::new();
286    while let Some(id) = stack.pop() {
287        if seen.insert(id) {
288            match plan.arena.get(id) {
289                Shape::Annotated { shape, .. } => stack.push(*shape),
290                Shape::Not(c) => stack.push(*c),
291                Shape::And(cs) | Shape::Or(cs) => stack.extend(cs.iter().copied()),
292                Shape::Count { qualifier, .. } => stack.push(*qualifier),
293                Shape::Expression(e) => e.referenced_shapes(&mut stack),
294                _ => {}
295            }
296        }
297    }
298    seen
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use shifty_algebra::{NodeKindSet, Statement};
305
306    fn nn(s: &str) -> NamedNode {
307        NamedNode::new(s).unwrap()
308    }
309
310    fn schema_with(arena: ShapeArena, selector: Selector, shape: ShapeId) -> Schema {
311        Schema {
312            arena,
313            statements: vec![Statement { selector, shape }],
314            rules: Vec::new(),
315            names: Default::default(),
316        }
317    }
318
319    #[test]
320    fn reorders_and_cheap_first() {
321        // And([expensive count over a star path, cheap nodeKind]) → cheap first
322        let mut a = ShapeArena::new();
323        let kind = a.insert(Shape::TestKind(NodeKindSet::IRI));
324        let top = a.insert(Shape::Top);
325        let star = Path::star(Path::Pred(nn("http://ex/p")));
326        let count = a.insert(Shape::Count {
327            path: star,
328            min: Some(1),
329            max: None,
330            qualifier: top,
331        });
332        let and = a.insert(Shape::And(vec![count, kind])); // expensive first
333        let p = plan(&schema_with(
334            a,
335            Selector::IsConst(Term::NamedNode(nn("http://ex/x"))),
336            and,
337        ));
338        match p.arena.get(and) {
339            Shape::And(cs) => assert_eq!(cs, &vec![kind, count]), // cheap nodeKind moved first
340            other => panic!("expected And, got {other:?}"),
341        }
342    }
343
344    #[test]
345    fn class_target_seeds_from_constant() {
346        // HasPath(rdf:type/subClassOf*, test(ex:Person)) → PathToConst seed
347        let mut a = ShapeArena::new();
348        let class = Term::NamedNode(nn("http://ex/Person"));
349        let test = a.insert(Shape::TestConst(class.clone()));
350        let path = Path::seq(vec![
351            Path::Pred(nn("http://www.w3.org/1999/02/22-rdf-syntax-ns#type")),
352            Path::star(Path::Pred(nn(
353                "http://www.w3.org/2000/01/rdf-schema#subClassOf",
354            ))),
355        ]);
356        let root = a.insert(Shape::TestKind(NodeKindSet::IRI));
357        let p = plan(&schema_with(a, Selector::HasPath(path.clone(), test), root));
358        assert_eq!(
359            p.statements[0].source,
360            FocusSource::PathToConst {
361                path,
362                target: class
363            }
364        );
365    }
366
367    #[test]
368    fn simple_selectors_compile() {
369        let mut a = ShapeArena::new();
370        let root = a.insert(Shape::Top);
371        let p = plan(&schema_with(a, Selector::HasOut(nn("http://ex/q")), root));
372        assert_eq!(
373            p.statements[0].source,
374            FocusSource::SubjectsOf(nn("http://ex/q"))
375        );
376    }
377}