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    let mut index_counter = 0;
127    let mut stack = Vec::new();
128    let mut indices: HashMap<String, usize> = HashMap::new();
129    let mut lowlinks: HashMap<String, usize> = HashMap::new();
130    let mut on_stack: HashSet<String> = HashSet::new();
131    let mut sccs: Vec<Vec<String>> = Vec::new();
132
133    #[allow(clippy::too_many_arguments)]
134    fn strongconnect(
135        v: &str,
136        graph: &DependencyGraph,
137        index_counter: &mut usize,
138        stack: &mut Vec<String>,
139        indices: &mut HashMap<String, usize>,
140        lowlinks: &mut HashMap<String, usize>,
141        on_stack: &mut HashSet<String>,
142        sccs: &mut Vec<Vec<String>>,
143    ) {
144        indices.insert(v.to_string(), *index_counter);
145        lowlinks.insert(v.to_string(), *index_counter);
146        *index_counter += 1;
147        stack.push(v.to_string());
148        on_stack.insert(v.to_string());
149
150        for edge in graph.outgoing(v) {
151            let w = &edge.to;
152            if !indices.contains_key(w) {
153                strongconnect(
154                    w,
155                    graph,
156                    index_counter,
157                    stack,
158                    indices,
159                    lowlinks,
160                    on_stack,
161                    sccs,
162                );
163                let low_v = *lowlinks.get(v).unwrap();
164                let low_w = *lowlinks.get(w).unwrap();
165                lowlinks.insert(v.to_string(), low_v.min(low_w));
166            } else if on_stack.contains(w) {
167                let low_v = *lowlinks.get(v).unwrap();
168                let idx_w = *indices.get(w).unwrap();
169                lowlinks.insert(v.to_string(), low_v.min(idx_w));
170            }
171        }
172
173        let low_v = *lowlinks.get(v).unwrap();
174        let idx_v = *indices.get(v).unwrap();
175        if low_v == idx_v {
176            let mut scc = Vec::new();
177            loop {
178                let w = stack.pop().unwrap();
179                on_stack.remove(&w);
180                scc.push(w.clone());
181                if w == v {
182                    break;
183                }
184            }
185            sccs.push(scc);
186        }
187    }
188
189    // Deterministic visit order: `predicates` is a HashSet, whose iteration
190    // order varies per process and used to make SCC ids (and every downstream
191    // RelId assignment) process-nondeterministic.
192    let mut preds_sorted: Vec<&String> = graph.predicates.iter().collect();
193    preds_sorted.sort();
194    for pred in preds_sorted {
195        if !indices.contains_key(pred) {
196            strongconnect(
197                pred,
198                graph,
199                &mut index_counter,
200                &mut stack,
201                &mut indices,
202                &mut lowlinks,
203                &mut on_stack,
204                &mut sccs,
205            );
206        }
207    }
208
209    sccs
210}
211
212/// Check for cycles through negation/aggregation in an SCC
213fn check_scc_for_negation_cycle(scc: &[String], graph: &DependencyGraph) -> Option<Vec<String>> {
214    if scc.len() == 1 {
215        let pred = &scc[0];
216        for edge in graph.outgoing(pred) {
217            if edge.to == *pred && edge.dep_type != DepType::Positive {
218                return Some(vec![pred.clone()]);
219            }
220        }
221        return None;
222    }
223
224    let scc_set: HashSet<&str> = scc.iter().map(|s| s.as_str()).collect();
225    for pred in scc {
226        for edge in graph.outgoing(pred) {
227            if scc_set.contains(edge.to.as_str()) && edge.dep_type != DepType::Positive {
228                return Some(scc.to_vec());
229            }
230        }
231    }
232    None
233}
234
235/// Stratum assignment result.
236#[derive(Debug, Clone)]
237pub struct Stratum {
238    /// Stratum index (0 = base stratum).
239    pub id: usize,
240    /// Predicates assigned to this stratum.
241    pub predicates: Vec<String>,
242}
243
244/// Result of stratification analysis for probabilistic inference
245#[derive(Debug, Clone)]
246pub struct StratificationResult {
247    /// SCCs in evaluation order (dependencies first)
248    pub sccs: Vec<Vec<String>>,
249    /// Indices of SCCs that have cycles through negation (non-monotone)
250    pub non_monotone_sccs: HashSet<usize>,
251    /// Stratum number for each predicate (if fully stratified)
252    pub strata: HashMap<String, usize>,
253}
254
255/// Perform stratification analysis
256pub fn stratify(program: &Program) -> Result<Vec<Stratum>> {
257    let graph = build_dependency_graph(program);
258    let sccs = find_sccs(&graph);
259
260    for scc in &sccs {
261        if let Some(cycle) = check_scc_for_negation_cycle(scc, &graph) {
262            // Non-monotone programs are now supported for exact_ddnnf via WFS
263            // Only reject for non-probabilistic programs that require stratification
264            if !program.is_probabilistic_profile() {
265                return Err(XlogError::StratificationCycle(cycle));
266            }
267            // Probabilistic programs (both exact_ddnnf and mc) can handle non-monotone cycles:
268            // - exact_ddnnf uses WFS to compute well-founded model
269            // - mc uses sampling which naturally handles non-stratified programs
270        }
271    }
272
273    let mut stratum_map: HashMap<String, usize> = HashMap::new();
274    let mut max_stratum = 0;
275
276    // Tarjan produces SCCs in reverse topological order.
277    // Since edges go from "dependent" to "dependency" (head -> body predicate),
278    // SCCs of dependencies come first. Process in order to ensure dependencies
279    // are assigned strata before dependents.
280    for scc in &sccs {
281        let mut min_stratum = 0;
282        for pred in scc {
283            for edge in graph.outgoing(pred) {
284                if let Some(&dep_stratum) = stratum_map.get(&edge.to) {
285                    let required = match edge.dep_type {
286                        DepType::Positive => dep_stratum,
287                        DepType::Negative | DepType::Aggregate => dep_stratum + 1,
288                    };
289                    min_stratum = min_stratum.max(required);
290                }
291            }
292        }
293        for pred in scc {
294            stratum_map.insert(pred.clone(), min_stratum);
295        }
296        max_stratum = max_stratum.max(min_stratum);
297    }
298
299    let mut strata: Vec<Stratum> = (0..=max_stratum)
300        .map(|id| Stratum {
301            id,
302            predicates: vec![],
303        })
304        .collect();
305
306    for (pred, stratum) in stratum_map {
307        strata[stratum].predicates.push(pred);
308    }
309
310    // `stratum_map` is a HashMap, so the drain order above varies per process;
311    // sort so `Stratum::predicates` is deterministic output.
312    for stratum in &mut strata {
313        stratum.predicates.sort();
314    }
315
316    strata.retain(|s| !s.predicates.is_empty());
317    for (i, stratum) in strata.iter_mut().enumerate() {
318        stratum.id = i;
319    }
320
321    Ok(strata)
322}
323
324/// Analyze stratification for probabilistic inference
325/// Returns detailed information about SCCs and which ones are non-monotone
326pub fn analyze_stratification(program: &Program) -> StratificationResult {
327    let graph = build_dependency_graph(program);
328    let sccs = find_sccs(&graph);
329
330    let mut non_monotone_sccs: HashSet<usize> = HashSet::new();
331    for (i, scc) in sccs.iter().enumerate() {
332        if check_scc_for_negation_cycle(scc, &graph).is_some() {
333            non_monotone_sccs.insert(i);
334        }
335    }
336
337    // Compute strata for predicates in stratified SCCs
338    let mut strata: HashMap<String, usize> = HashMap::new();
339    let mut max_stratum = 0;
340
341    for (scc_idx, scc) in sccs.iter().enumerate() {
342        if non_monotone_sccs.contains(&scc_idx) {
343            continue; // Skip non-monotone SCCs for stratum assignment
344        }
345
346        let mut min_stratum = 0;
347        for pred in scc {
348            for edge in graph.outgoing(pred) {
349                if let Some(&dep_stratum) = strata.get(&edge.to) {
350                    let required = match edge.dep_type {
351                        DepType::Positive => dep_stratum,
352                        DepType::Negative | DepType::Aggregate => dep_stratum + 1,
353                    };
354                    min_stratum = min_stratum.max(required);
355                }
356            }
357        }
358        for pred in scc {
359            strata.insert(pred.clone(), min_stratum);
360        }
361        max_stratum = max_stratum.max(min_stratum);
362    }
363
364    StratificationResult {
365        sccs,
366        non_monotone_sccs,
367        strata,
368    }
369}
370
371/// Find SCCs for the lowering phase
372/// Returns SCCs in reverse topological order (dependencies first)
373pub fn find_sccs_for_lowering(graph: &DependencyGraph) -> Vec<Vec<String>> {
374    find_sccs(graph)
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::ast::*;
381
382    fn create_tc_program() -> Program {
383        let mut program = Program::new();
384        program.rules.push(Rule {
385            head: Atom {
386                predicate: "edge".into(),
387                terms: vec![Term::Integer(1), Term::Integer(2)],
388            },
389            body: vec![],
390        });
391        program.rules.push(Rule {
392            head: Atom {
393                predicate: "reach".into(),
394                terms: vec![Term::Variable("X".into()), Term::Variable("Y".into())],
395            },
396            body: vec![BodyLiteral::Positive(Atom {
397                predicate: "edge".into(),
398                terms: vec![Term::Variable("X".into()), Term::Variable("Y".into())],
399            })],
400        });
401        program.rules.push(Rule {
402            head: Atom {
403                predicate: "reach".into(),
404                terms: vec![Term::Variable("X".into()), Term::Variable("Z".into())],
405            },
406            body: vec![
407                BodyLiteral::Positive(Atom {
408                    predicate: "reach".into(),
409                    terms: vec![Term::Variable("X".into()), Term::Variable("Y".into())],
410                }),
411                BodyLiteral::Positive(Atom {
412                    predicate: "edge".into(),
413                    terms: vec![Term::Variable("Y".into()), Term::Variable("Z".into())],
414                }),
415            ],
416        });
417        program
418    }
419
420    fn create_isolated_program() -> Program {
421        let mut program = Program::new();
422        for i in 1..=3 {
423            program.rules.push(Rule {
424                head: Atom {
425                    predicate: "node".into(),
426                    terms: vec![Term::Integer(i)],
427                },
428                body: vec![],
429            });
430        }
431        program.rules.push(Rule {
432            head: Atom {
433                predicate: "edge".into(),
434                terms: vec![Term::Integer(1), Term::Integer(2)],
435            },
436            body: vec![],
437        });
438        program.rules.push(Rule {
439            head: Atom {
440                predicate: "isolated".into(),
441                terms: vec![Term::Variable("X".into())],
442            },
443            body: vec![
444                BodyLiteral::Positive(Atom {
445                    predicate: "node".into(),
446                    terms: vec![Term::Variable("X".into())],
447                }),
448                BodyLiteral::Negated(Atom {
449                    predicate: "edge".into(),
450                    terms: vec![Term::Variable("X".into()), Term::Variable("Y".into())],
451                }),
452            ],
453        });
454        program
455    }
456
457    fn create_unstratifiable_program() -> Program {
458        let mut program = Program::new();
459        program.rules.push(Rule {
460            head: Atom {
461                predicate: "p".into(),
462                terms: vec![],
463            },
464            body: vec![BodyLiteral::Negated(Atom {
465                predicate: "q".into(),
466                terms: vec![],
467            })],
468        });
469        program.rules.push(Rule {
470            head: Atom {
471                predicate: "q".into(),
472                terms: vec![],
473            },
474            body: vec![BodyLiteral::Negated(Atom {
475                predicate: "p".into(),
476                terms: vec![],
477            })],
478        });
479        program
480    }
481
482    #[test]
483    fn test_stratify_simple() {
484        let program = create_tc_program();
485        let result = stratify(&program);
486        assert!(result.is_ok(), "Stratification failed: {:?}", result.err());
487    }
488
489    #[test]
490    fn test_stratify_with_negation() {
491        let program = create_isolated_program();
492        let result = stratify(&program);
493        assert!(result.is_ok(), "Stratification failed: {:?}", result.err());
494        let strata = result.unwrap();
495        assert!(
496            strata.len() >= 2,
497            "Expected at least 2 strata, got {}",
498            strata.len()
499        );
500    }
501
502    #[test]
503    fn test_stratify_cycle_through_negation() {
504        let program = create_unstratifiable_program();
505        let result = stratify(&program);
506        assert!(result.is_err(), "Should fail with cycle through negation");
507        if let Err(XlogError::StratificationCycle(preds)) = result {
508            assert!(preds.contains(&"p".to_string()) || preds.contains(&"q".to_string()));
509        }
510    }
511
512    #[test]
513    fn test_stratify_probabilistic_non_monotone_allows_exact_ddnnf() {
514        // Non-monotone programs are now supported with exact_ddnnf via WFS
515        let mut program = create_unstratifiable_program();
516        program.directives.prob_engine = Some(ProbEngine::ExactDdnnf);
517
518        let result = stratify(&program);
519        assert!(
520            result.is_ok(),
521            "Expected exact_ddnnf to allow non-monotone recursion (via WFS), got: {:?}",
522            result.err()
523        );
524    }
525
526    #[test]
527    fn test_stratify_probabilistic_non_monotone_allows_mc() {
528        let mut program = create_unstratifiable_program();
529        program.directives.prob_engine = Some(ProbEngine::Mc);
530
531        let result = stratify(&program);
532        assert!(
533            result.is_ok(),
534            "Expected mc to allow non-monotone recursion, got: {:?}",
535            result.err()
536        );
537    }
538
539    #[test]
540    fn test_dependency_graph_construction() {
541        let program = create_tc_program();
542        let graph = build_dependency_graph(&program);
543        assert!(graph.predicates.contains("edge"));
544        assert!(graph.predicates.contains("reach"));
545        let reach_deps = graph.outgoing("reach");
546        assert!(!reach_deps.is_empty());
547    }
548
549    #[test]
550    fn test_analyze_stratification_detects_non_monotone() {
551        let program = create_unstratifiable_program(); // p :- not q. q :- not p.
552        let result = analyze_stratification(&program);
553
554        assert!(
555            !result.non_monotone_sccs.is_empty(),
556            "Should detect non-monotone SCC"
557        );
558        // The SCC containing p and q should be marked as non-monotone
559        let has_non_monotone = result.sccs.iter().enumerate().any(|(i, scc)| {
560            result.non_monotone_sccs.contains(&i)
561                && (scc.contains(&"p".to_string()) || scc.contains(&"q".to_string()))
562        });
563        assert!(has_non_monotone, "SCC with p/q should be non-monotone");
564    }
565
566    #[test]
567    fn test_analyze_stratification_stratified_program() {
568        let program = create_isolated_program(); // isolated(X) :- node(X), not edge(X, Y).
569        let result = analyze_stratification(&program);
570
571        assert!(
572            result.non_monotone_sccs.is_empty(),
573            "Stratified program has no non-monotone SCCs"
574        );
575        assert!(
576            result.strata.contains_key("isolated"),
577            "isolated should have a stratum"
578        );
579        assert!(
580            result.strata.contains_key("edge"),
581            "edge should have a stratum"
582        );
583
584        // isolated depends negatively on edge, so isolated.stratum > edge.stratum
585        let isolated_stratum = result.strata.get("isolated").unwrap();
586        let edge_stratum = result.strata.get("edge").unwrap();
587        assert!(
588            isolated_stratum > edge_stratum,
589            "isolated should be in higher stratum than edge"
590        );
591    }
592}