Skip to main content

xlog_logic/
stratify.rs

1//! Stratification analysis for negation and aggregation
2
3use crate::ast::{BodyLiteral, Program};
4use std::collections::{HashMap, HashSet};
5use xlog_core::{Result, XlogError};
6
7/// Dependency edge type
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub(crate) enum DepType {
10    Positive,
11    Negative,
12    Aggregate,
13}
14
15/// Dependency graph edge
16#[derive(Debug, Clone)]
17pub(crate) struct DepEdge {
18    pub from: String,
19    pub to: String,
20    pub dep_type: DepType,
21}
22
23/// Dependency graph for stratification analysis.
24#[derive(Debug, Default)]
25pub struct DependencyGraph {
26    /// Set of all predicate names in the graph.
27    pub predicates: HashSet<String>,
28    /// Private so every insertion goes through `add_edge`, which keeps
29    /// `adjacency` in sync — a bare `edges.push` would silently drop the edge
30    /// from `outgoing()`.
31    edges: Vec<DepEdge>,
32    /// Outgoing-edge indices per predicate, kept in insertion order so
33    /// `outgoing()` returns edges exactly as the linear scan over `edges` did.
34    adjacency: std::collections::HashMap<String, Vec<usize>>,
35}
36
37impl DependencyGraph {
38    /// Create an empty dependency graph.
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Add a predicate node to the graph.
44    pub fn add_predicate(&mut self, name: String) {
45        self.predicates.insert(name);
46    }
47
48    pub(crate) fn add_edge(&mut self, from: String, to: String, dep_type: DepType) {
49        self.predicates.insert(from.clone());
50        self.predicates.insert(to.clone());
51        self.adjacency
52            .entry(from.clone())
53            .or_default()
54            .push(self.edges.len());
55        self.edges.push(DepEdge { from, to, dep_type });
56    }
57
58    pub(crate) fn outgoing(&self, pred: &str) -> Vec<&DepEdge> {
59        match self.adjacency.get(pred) {
60            Some(idxs) => idxs
61                .iter()
62                .map(|&i| {
63                    let edge = &self.edges[i];
64                    debug_assert_eq!(edge.from, pred, "adjacency index out of sync");
65                    edge
66                })
67                .collect(),
68            None => Vec::new(),
69        }
70    }
71}
72
73/// Build dependency graph from program
74pub fn build_dependency_graph(program: &Program) -> DependencyGraph {
75    let mut graph = DependencyGraph::new();
76
77    for rule in &program.rules {
78        let head = &rule.head.predicate;
79        graph.add_predicate(head.clone());
80
81        for lit in &rule.body {
82            match lit {
83                BodyLiteral::Positive(atom) => {
84                    graph.add_edge(head.clone(), atom.predicate.clone(), DepType::Positive);
85                }
86                BodyLiteral::Negated(atom) => {
87                    graph.add_edge(head.clone(), atom.predicate.clone(), DepType::Negative);
88                }
89                BodyLiteral::Epistemic(lit) => {
90                    graph.add_edge(head.clone(), lit.atom.predicate.clone(), DepType::Negative);
91                }
92                BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {}
93            }
94        }
95
96        if rule.has_aggregation() {
97            for lit in &rule.body {
98                if let BodyLiteral::Positive(atom) = lit {
99                    graph.add_edge(head.clone(), atom.predicate.clone(), DepType::Aggregate);
100                }
101            }
102        }
103    }
104
105    // Learnable rules: head depends on body predicates.
106    // At runtime, TensorMaskedJoin dynamically selects which relations
107    // to join, but for stratification we conservatively register the
108    // template's body predicates as positive dependencies.
109    for lr in &program.learnable_rules {
110        let head = &lr.head.predicate;
111        graph.add_predicate(head.clone());
112        for body_lit in &lr.body {
113            if let Some(atom) = body_lit.atom() {
114                graph.add_predicate(atom.predicate.clone());
115                graph.add_edge(head.clone(), atom.predicate.clone(), DepType::Positive);
116            }
117        }
118    }
119
120    graph
121}
122
123/// Find strongly connected components using Tarjan's algorithm
124/// Returns SCCs in reverse topological order (dependencies first)
125fn find_sccs(graph: &DependencyGraph) -> Vec<Vec<String>> {
126    struct TarjanState<'a> {
127        graph: &'a DependencyGraph,
128        next_index: usize,
129        stack: Vec<String>,
130        indices: HashMap<String, usize>,
131        lowlinks: HashMap<String, usize>,
132        on_stack: HashSet<String>,
133        components: Vec<Vec<String>>,
134    }
135
136    impl TarjanState<'_> {
137        fn strongconnect(&mut self, vertex: &str) {
138            self.indices.insert(vertex.to_string(), self.next_index);
139            self.lowlinks.insert(vertex.to_string(), self.next_index);
140            self.next_index += 1;
141            self.stack.push(vertex.to_string());
142            self.on_stack.insert(vertex.to_string());
143
144            let successors: Vec<String> = self
145                .graph
146                .outgoing(vertex)
147                .iter()
148                .map(|edge| edge.to.clone())
149                .collect();
150            for successor in successors {
151                if !self.indices.contains_key(&successor) {
152                    self.strongconnect(&successor);
153                    let low_vertex = self.lowlinks[vertex];
154                    let low_successor = self.lowlinks[&successor];
155                    self.lowlinks
156                        .insert(vertex.to_string(), low_vertex.min(low_successor));
157                } else if self.on_stack.contains(&successor) {
158                    let low_vertex = self.lowlinks[vertex];
159                    let successor_index = self.indices[&successor];
160                    self.lowlinks
161                        .insert(vertex.to_string(), low_vertex.min(successor_index));
162                }
163            }
164
165            if self.lowlinks[vertex] == self.indices[vertex] {
166                let mut component = Vec::new();
167                loop {
168                    let member = self
169                        .stack
170                        .pop()
171                        .expect("the current Tarjan root remains on the traversal stack");
172                    self.on_stack.remove(&member);
173                    let is_root = member == vertex;
174                    component.push(member);
175                    if is_root {
176                        break;
177                    }
178                }
179                self.components.push(component);
180            }
181        }
182    }
183
184    let mut state = TarjanState {
185        graph,
186        next_index: 0,
187        stack: Vec::new(),
188        indices: HashMap::new(),
189        lowlinks: HashMap::new(),
190        on_stack: HashSet::new(),
191        components: Vec::new(),
192    };
193
194    // Deterministic visit order: `predicates` is a HashSet, whose iteration
195    // order varies per process and used to make SCC ids (and every downstream
196    // RelId assignment) process-nondeterministic.
197    let mut preds_sorted: Vec<&String> = graph.predicates.iter().collect();
198    preds_sorted.sort();
199    for pred in preds_sorted {
200        if !state.indices.contains_key(pred) {
201            state.strongconnect(pred);
202        }
203    }
204
205    state.components
206}
207
208/// Check for cycles through negation/aggregation in an SCC
209fn check_scc_for_negation_cycle(scc: &[String], graph: &DependencyGraph) -> Option<Vec<String>> {
210    if scc.len() == 1 {
211        let pred = &scc[0];
212        for edge in graph.outgoing(pred) {
213            if edge.to == *pred && edge.dep_type != DepType::Positive {
214                return Some(vec![pred.clone()]);
215            }
216        }
217        return None;
218    }
219
220    let scc_set: HashSet<&str> = scc.iter().map(|s| s.as_str()).collect();
221    for pred in scc {
222        for edge in graph.outgoing(pred) {
223            if scc_set.contains(edge.to.as_str()) && edge.dep_type != DepType::Positive {
224                return Some(scc.to_vec());
225            }
226        }
227    }
228    None
229}
230
231/// Stratum assignment result.
232#[derive(Debug, Clone)]
233pub struct Stratum {
234    /// Stratum index (0 = base stratum).
235    pub id: usize,
236    /// Predicates assigned to this stratum.
237    pub predicates: Vec<String>,
238}
239
240/// Result of stratification analysis for probabilistic inference
241#[derive(Debug, Clone)]
242pub struct StratificationResult {
243    /// SCCs in evaluation order (dependencies first)
244    pub sccs: Vec<Vec<String>>,
245    /// Indices of SCCs that have cycles through negation (non-monotone)
246    pub non_monotone_sccs: HashSet<usize>,
247    /// Stratum number for each predicate (if fully stratified)
248    pub strata: HashMap<String, usize>,
249}
250
251/// Perform stratification analysis
252pub fn stratify(program: &Program) -> Result<Vec<Stratum>> {
253    let graph = build_dependency_graph(program);
254    let sccs = find_sccs(&graph);
255
256    for scc in &sccs {
257        if let Some(cycle) = check_scc_for_negation_cycle(scc, &graph) {
258            // Non-monotone programs are now supported for exact_ddnnf via WFS
259            // Only reject for non-probabilistic programs that require stratification
260            if !program.is_probabilistic_profile() {
261                return Err(XlogError::StratificationCycle(cycle));
262            }
263            // Probabilistic programs (both exact_ddnnf and mc) can handle non-monotone cycles:
264            // - exact_ddnnf uses WFS to compute well-founded model
265            // - mc uses sampling which naturally handles non-stratified programs
266        }
267    }
268
269    let mut stratum_map: HashMap<String, usize> = HashMap::new();
270    let mut max_stratum = 0;
271
272    // Tarjan produces SCCs in reverse topological order.
273    // Since edges go from "dependent" to "dependency" (head -> body predicate),
274    // SCCs of dependencies come first. Process in order to ensure dependencies
275    // are assigned strata before dependents.
276    for scc in &sccs {
277        let mut min_stratum = 0;
278        for pred in scc {
279            for edge in graph.outgoing(pred) {
280                if let Some(&dep_stratum) = stratum_map.get(&edge.to) {
281                    let required = match edge.dep_type {
282                        DepType::Positive => dep_stratum,
283                        DepType::Negative | DepType::Aggregate => dep_stratum + 1,
284                    };
285                    min_stratum = min_stratum.max(required);
286                }
287            }
288        }
289        for pred in scc {
290            stratum_map.insert(pred.clone(), min_stratum);
291        }
292        max_stratum = max_stratum.max(min_stratum);
293    }
294
295    let mut strata: Vec<Stratum> = (0..=max_stratum)
296        .map(|id| Stratum {
297            id,
298            predicates: vec![],
299        })
300        .collect();
301
302    for (pred, stratum) in stratum_map {
303        strata[stratum].predicates.push(pred);
304    }
305
306    // `stratum_map` is a HashMap, so the drain order above varies per process;
307    // sort so `Stratum::predicates` is deterministic output.
308    for stratum in &mut strata {
309        stratum.predicates.sort();
310    }
311
312    strata.retain(|s| !s.predicates.is_empty());
313    for (i, stratum) in strata.iter_mut().enumerate() {
314        stratum.id = i;
315    }
316
317    Ok(strata)
318}
319
320/// Analyze stratification for probabilistic inference
321/// Returns detailed information about SCCs and which ones are non-monotone
322pub fn analyze_stratification(program: &Program) -> StratificationResult {
323    let graph = build_dependency_graph(program);
324    let sccs = find_sccs(&graph);
325
326    let mut non_monotone_sccs: HashSet<usize> = HashSet::new();
327    for (i, scc) in sccs.iter().enumerate() {
328        if check_scc_for_negation_cycle(scc, &graph).is_some() {
329            non_monotone_sccs.insert(i);
330        }
331    }
332
333    // Compute strata for predicates in stratified SCCs
334    let mut strata: HashMap<String, usize> = HashMap::new();
335    let mut max_stratum = 0;
336
337    for (scc_idx, scc) in sccs.iter().enumerate() {
338        if non_monotone_sccs.contains(&scc_idx) {
339            continue; // Skip non-monotone SCCs for stratum assignment
340        }
341
342        let mut min_stratum = 0;
343        for pred in scc {
344            for edge in graph.outgoing(pred) {
345                if let Some(&dep_stratum) = strata.get(&edge.to) {
346                    let required = match edge.dep_type {
347                        DepType::Positive => dep_stratum,
348                        DepType::Negative | DepType::Aggregate => dep_stratum + 1,
349                    };
350                    min_stratum = min_stratum.max(required);
351                }
352            }
353        }
354        for pred in scc {
355            strata.insert(pred.clone(), min_stratum);
356        }
357        max_stratum = max_stratum.max(min_stratum);
358    }
359
360    StratificationResult {
361        sccs,
362        non_monotone_sccs,
363        strata,
364    }
365}
366
367/// Find SCCs for the lowering phase
368/// Returns SCCs in reverse topological order (dependencies first)
369pub fn find_sccs_for_lowering(graph: &DependencyGraph) -> Vec<Vec<String>> {
370    find_sccs(graph)
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::ast::*;
377
378    fn create_tc_program() -> Program {
379        let mut program = Program::new();
380        program.rules.push(Rule {
381            head: Atom {
382                predicate: "edge".into(),
383                terms: vec![Term::Integer(1), Term::Integer(2)],
384            },
385            body: vec![],
386        });
387        program.rules.push(Rule {
388            head: Atom {
389                predicate: "reach".into(),
390                terms: vec![Term::Variable("X".into()), Term::Variable("Y".into())],
391            },
392            body: vec![BodyLiteral::Positive(Atom {
393                predicate: "edge".into(),
394                terms: vec![Term::Variable("X".into()), Term::Variable("Y".into())],
395            })],
396        });
397        program.rules.push(Rule {
398            head: Atom {
399                predicate: "reach".into(),
400                terms: vec![Term::Variable("X".into()), Term::Variable("Z".into())],
401            },
402            body: vec![
403                BodyLiteral::Positive(Atom {
404                    predicate: "reach".into(),
405                    terms: vec![Term::Variable("X".into()), Term::Variable("Y".into())],
406                }),
407                BodyLiteral::Positive(Atom {
408                    predicate: "edge".into(),
409                    terms: vec![Term::Variable("Y".into()), Term::Variable("Z".into())],
410                }),
411            ],
412        });
413        program
414    }
415
416    fn create_isolated_program() -> Program {
417        let mut program = Program::new();
418        for i in 1..=3 {
419            program.rules.push(Rule {
420                head: Atom {
421                    predicate: "node".into(),
422                    terms: vec![Term::Integer(i)],
423                },
424                body: vec![],
425            });
426        }
427        program.rules.push(Rule {
428            head: Atom {
429                predicate: "edge".into(),
430                terms: vec![Term::Integer(1), Term::Integer(2)],
431            },
432            body: vec![],
433        });
434        program.rules.push(Rule {
435            head: Atom {
436                predicate: "isolated".into(),
437                terms: vec![Term::Variable("X".into())],
438            },
439            body: vec![
440                BodyLiteral::Positive(Atom {
441                    predicate: "node".into(),
442                    terms: vec![Term::Variable("X".into())],
443                }),
444                BodyLiteral::Negated(Atom {
445                    predicate: "edge".into(),
446                    terms: vec![Term::Variable("X".into()), Term::Variable("Y".into())],
447                }),
448            ],
449        });
450        program
451    }
452
453    fn create_unstratifiable_program() -> Program {
454        let mut program = Program::new();
455        program.rules.push(Rule {
456            head: Atom {
457                predicate: "p".into(),
458                terms: vec![],
459            },
460            body: vec![BodyLiteral::Negated(Atom {
461                predicate: "q".into(),
462                terms: vec![],
463            })],
464        });
465        program.rules.push(Rule {
466            head: Atom {
467                predicate: "q".into(),
468                terms: vec![],
469            },
470            body: vec![BodyLiteral::Negated(Atom {
471                predicate: "p".into(),
472                terms: vec![],
473            })],
474        });
475        program
476    }
477
478    #[test]
479    fn test_stratify_simple() {
480        let program = create_tc_program();
481        let result = stratify(&program);
482        assert!(result.is_ok(), "Stratification failed: {:?}", result.err());
483    }
484
485    #[test]
486    fn test_stratify_with_negation() {
487        let program = create_isolated_program();
488        let result = stratify(&program);
489        assert!(result.is_ok(), "Stratification failed: {:?}", result.err());
490        let strata = result.unwrap();
491        assert!(
492            strata.len() >= 2,
493            "Expected at least 2 strata, got {}",
494            strata.len()
495        );
496    }
497
498    #[test]
499    fn test_stratify_cycle_through_negation() {
500        let program = create_unstratifiable_program();
501        let result = stratify(&program);
502        assert!(result.is_err(), "Should fail with cycle through negation");
503        if let Err(XlogError::StratificationCycle(preds)) = result {
504            assert!(preds.contains(&"p".to_string()) || preds.contains(&"q".to_string()));
505        }
506    }
507
508    #[test]
509    fn test_stratify_probabilistic_non_monotone_allows_exact_ddnnf() {
510        // Non-monotone programs are now supported with exact_ddnnf via WFS
511        let mut program = create_unstratifiable_program();
512        program.directives.prob_engine = Some(ProbEngine::ExactDdnnf);
513
514        let result = stratify(&program);
515        assert!(
516            result.is_ok(),
517            "Expected exact_ddnnf to allow non-monotone recursion (via WFS), got: {:?}",
518            result.err()
519        );
520    }
521
522    #[test]
523    fn test_stratify_probabilistic_non_monotone_allows_mc() {
524        let mut program = create_unstratifiable_program();
525        program.directives.prob_engine = Some(ProbEngine::Mc);
526
527        let result = stratify(&program);
528        assert!(
529            result.is_ok(),
530            "Expected mc to allow non-monotone recursion, got: {:?}",
531            result.err()
532        );
533    }
534
535    #[test]
536    fn test_dependency_graph_construction() {
537        let program = create_tc_program();
538        let graph = build_dependency_graph(&program);
539        assert!(graph.predicates.contains("edge"));
540        assert!(graph.predicates.contains("reach"));
541        let reach_deps = graph.outgoing("reach");
542        assert!(!reach_deps.is_empty());
543    }
544
545    #[test]
546    fn test_analyze_stratification_detects_non_monotone() {
547        let program = create_unstratifiable_program(); // p :- not q. q :- not p.
548        let result = analyze_stratification(&program);
549
550        assert!(
551            !result.non_monotone_sccs.is_empty(),
552            "Should detect non-monotone SCC"
553        );
554        // The SCC containing p and q should be marked as non-monotone
555        let has_non_monotone = result.sccs.iter().enumerate().any(|(i, scc)| {
556            result.non_monotone_sccs.contains(&i)
557                && (scc.contains(&"p".to_string()) || scc.contains(&"q".to_string()))
558        });
559        assert!(has_non_monotone, "SCC with p/q should be non-monotone");
560    }
561
562    #[test]
563    fn test_analyze_stratification_stratified_program() {
564        let program = create_isolated_program(); // isolated(X) :- node(X), not edge(X, Y).
565        let result = analyze_stratification(&program);
566
567        assert!(
568            result.non_monotone_sccs.is_empty(),
569            "Stratified program has no non-monotone SCCs"
570        );
571        assert!(
572            result.strata.contains_key("isolated"),
573            "isolated should have a stratum"
574        );
575        assert!(
576            result.strata.contains_key("edge"),
577            "edge should have a stratum"
578        );
579
580        // isolated depends negatively on edge, so isolated.stratum > edge.stratum
581        let isolated_stratum = result.strata.get("isolated").unwrap();
582        let edge_stratum = result.strata.get("edge").unwrap();
583        assert!(
584            isolated_stratum > edge_stratum,
585            "isolated should be in higher stratum than edge"
586        );
587    }
588}