Skip to main content

shifty_opt/
strata.rs

1//! Stratification analysis over the polarity-aware dependency graph (Layer 4).
2//!
3//! We condense strongly-connected components and order them by dependency depth
4//! (dependees first) — each SCC is one stratum for the fixpoint engine. A
5//! recursive SCC is **stratifiable** iff it is *sign-balanced*: no cycle has
6//! net-negative (odd) polarity. This is the correct test for our IR, where the
7//! `∃≤0 π.¬φ` encoding of `∀` produces paired negative edges that compose to
8//! positive (`docs/03-recursion-semantics.md`). A non-stratifiable SCC is a
9//! recursion through genuine negation (e.g. `S := ¬∃p.S`) and is reported.
10
11use crate::deps::{DepEdge, dependency_edges};
12use petgraph::algo::tarjan_scc;
13use petgraph::graph::{DiGraph, NodeIndex};
14use serde::{Deserialize, Serialize};
15use shifty_algebra::{ShapeArena, ShapeId};
16use std::collections::{HashMap, HashSet};
17
18/// One stratum: a set of shapes evaluated together as a fixpoint.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Stratum {
21    pub shapes: Vec<ShapeId>,
22    /// True if the SCC has more than one member or a self-loop.
23    pub recursive: bool,
24    /// True unless this is a recursion through net negation.
25    pub stratifiable: bool,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Stratification {
30    /// Strata in evaluation order (dependees first).
31    pub strata: Vec<Stratum>,
32    /// False iff any recursive SCC is sign-unbalanced.
33    pub stratifiable: bool,
34}
35
36impl Stratification {
37    pub fn shape_count(&self) -> usize {
38        self.strata.iter().map(|s| s.shapes.len()).sum()
39    }
40
41    pub fn recursive(&self) -> impl Iterator<Item = &Stratum> {
42        self.strata.iter().filter(|s| s.recursive)
43    }
44}
45
46/// Analyze the recursion structure of an arena.
47pub fn analyze(arena: &ShapeArena) -> Stratification {
48    let edges = dependency_edges(arena);
49
50    let mut graph = DiGraph::<ShapeId, ()>::new();
51    let mut node_of: HashMap<ShapeId, NodeIndex> = HashMap::new();
52    for i in 0..arena.len() {
53        let id = ShapeId(i as u32);
54        node_of.insert(id, graph.add_node(id));
55    }
56    for e in &edges {
57        graph.add_edge(node_of[&e.from], node_of[&e.to], ());
58    }
59
60    let sccs = tarjan_scc(&graph);
61
62    // scc index per shape
63    let mut scc_of: HashMap<ShapeId, usize> = HashMap::new();
64    for (i, scc) in sccs.iter().enumerate() {
65        for n in scc {
66            scc_of.insert(graph[*n], i);
67        }
68    }
69
70    let self_loops: HashSet<ShapeId> = edges
71        .iter()
72        .filter(|e| e.from == e.to)
73        .map(|e| e.from)
74        .collect();
75
76    // depth of each SCC = longest dependency chain to a dependee (dependees = 0)
77    let depths = scc_depths(&sccs, &edges, &scc_of);
78
79    let mut indexed: Vec<(usize, Stratum)> = sccs
80        .iter()
81        .enumerate()
82        .map(|(i, scc)| {
83            let mut shapes: Vec<ShapeId> = scc.iter().map(|n| graph[*n]).collect();
84            shapes.sort();
85            let recursive = shapes.len() > 1 || shapes.iter().any(|s| self_loops.contains(s));
86            let members: HashSet<ShapeId> = shapes.iter().copied().collect();
87            let stratifiable = !recursive || sign_balanced(&members, &edges);
88            (
89                depths[i],
90                Stratum {
91                    shapes,
92                    recursive,
93                    stratifiable,
94                },
95            )
96        })
97        .collect();
98
99    indexed.sort_by_key(|(d, s)| (*d, s.shapes.first().copied()));
100    let strata: Vec<Stratum> = indexed.into_iter().map(|(_, s)| s).collect();
101    let stratifiable = strata.iter().all(|s| s.stratifiable);
102
103    Stratification {
104        strata,
105        stratifiable,
106    }
107}
108
109/// Longest dependency-chain depth of each SCC (an SCC with no out-edges to other
110/// SCCs has depth 0; a dependant is one deeper than its deepest dependee).
111fn scc_depths(
112    sccs: &[Vec<NodeIndex>],
113    edges: &[DepEdge],
114    scc_of: &HashMap<ShapeId, usize>,
115) -> Vec<usize> {
116    let mut succ: Vec<HashSet<usize>> = vec![HashSet::new(); sccs.len()];
117    for e in edges {
118        let (a, b) = (scc_of[&e.from], scc_of[&e.to]);
119        if a != b {
120            succ[a].insert(b);
121        }
122    }
123    let mut memo = vec![None; sccs.len()];
124    for i in 0..sccs.len() {
125        depth_of(i, &succ, &mut memo);
126    }
127    memo.into_iter().map(|d| d.unwrap()).collect()
128}
129
130fn depth_of(i: usize, succ: &[HashSet<usize>], memo: &mut [Option<usize>]) -> usize {
131    if let Some(d) = memo[i] {
132        return d;
133    }
134    memo[i] = Some(0); // guard (condensation is a DAG, but be safe)
135    let d = succ[i]
136        .iter()
137        .map(|&j| 1 + depth_of(j, succ, memo))
138        .max()
139        .unwrap_or(0);
140    memo[i] = Some(d);
141    d
142}
143
144/// Is the signed subgraph induced by `members` balanced? Assign each node a sign
145/// potential by BFS (`σ(b) = σ(a)·polarity` along every internal edge); a
146/// conflict means an odd-polarity cycle exists.
147fn sign_balanced(members: &HashSet<ShapeId>, edges: &[DepEdge]) -> bool {
148    let mut adj: HashMap<ShapeId, Vec<(ShapeId, i8)>> = HashMap::new();
149    for e in edges {
150        if members.contains(&e.from) && members.contains(&e.to) {
151            let s = e.polarity.sign();
152            adj.entry(e.from).or_default().push((e.to, s));
153            adj.entry(e.to).or_default().push((e.from, s));
154        }
155    }
156
157    let mut sigma: HashMap<ShapeId, i8> = HashMap::new();
158    for &start in members {
159        if sigma.contains_key(&start) {
160            continue;
161        }
162        sigma.insert(start, 1);
163        let mut stack = vec![start];
164        while let Some(a) = stack.pop() {
165            let sa = sigma[&a];
166            if let Some(neighbours) = adj.get(&a) {
167                for &(b, s) in neighbours {
168                    let want = sa * s;
169                    match sigma.get(&b) {
170                        Some(&sb) if sb != want => return false,
171                        Some(_) => {}
172                        None => {
173                            sigma.insert(b, want);
174                            stack.push(b);
175                        }
176                    }
177                }
178            }
179        }
180    }
181    true
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use shifty_algebra::{NamedNode, Path, Shape};
188
189    fn pred() -> NamedNode {
190        NamedNode::new("http://ex/p").unwrap()
191    }
192
193    #[test]
194    fn positive_recursion_is_stratifiable() {
195        // S := ∀p.S  ==  ∃≤0 p.¬S  (sh:node self-reference)
196        let mut arena = ShapeArena::new();
197        let s = arena.reserve();
198        let ns = arena.insert(Shape::Not(s));
199        arena.set(
200            s,
201            Shape::Count {
202                path: Path::Pred(pred()),
203                min: None,
204                max: Some(0),
205                qualifier: ns,
206            },
207        );
208
209        let strat = analyze(&arena);
210        assert!(strat.stratifiable, "∀-recursion must be stratifiable");
211        // s and ns form one recursive, balanced SCC
212        let rec: Vec<_> = strat.recursive().collect();
213        assert_eq!(rec.len(), 1);
214        assert!(rec[0].shapes.contains(&s) && rec[0].shapes.contains(&ns));
215    }
216
217    #[test]
218    fn negation_recursion_is_not_stratifiable() {
219        // S := ¬∃p.S  ==  ¬(∃≥1 p.S)
220        let mut arena = ShapeArena::new();
221        let s = arena.reserve();
222        let exists = arena.insert(Shape::Count {
223            path: Path::Pred(pred()),
224            min: Some(1),
225            max: None,
226            qualifier: s,
227        });
228        arena.set(s, Shape::Not(exists));
229
230        let strat = analyze(&arena);
231        assert!(!strat.stratifiable, "¬∃-recursion must be flagged");
232    }
233
234    #[test]
235    fn acyclic_schema_has_no_recursion() {
236        let mut arena = ShapeArena::new();
237        let a = arena.insert(Shape::TestKind(shifty_algebra::NodeKindSet::IRI));
238        let _b = arena.insert(Shape::Not(a));
239        let strat = analyze(&arena);
240        assert!(strat.stratifiable);
241        assert_eq!(strat.recursive().count(), 0);
242    }
243}