Skip to main content

xlog_prob/
wfs.rs

1//! Well-Founded Semantics for non-monotone probabilistic programs.
2//!
3//! WFS handles programs with cycles through negation using three-valued logic:
4//! - True: definitely derivable
5//! - False: definitely not derivable
6//! - Undefined: in cycle, neither provable nor refutable
7//!
8//! # Algorithm Overview
9//!
10//! The WFS alternating fixed-point algorithm works as follows:
11//!
12//! 1. **Initialize**: All atoms start as undefined
13//! 2. **Loop until fixed point**:
14//!    a. **Unfounded set computation**: Find atoms that cannot be supported
15//!       - An atom is unfounded if every rule that derives it either:
16//!         - Has a body literal that is known false, or
17//!         - Depends positively on an unfounded atom
18//!           b. **Mark unfounded atoms as false**
19//!           c. **Consequence derivation**: Find atoms that must be true
20//!       - An atom is a consequence if some rule has:
21//!         - All positive body literals true
22//!         - All negative body literals false
23//!           d. **Mark consequences as true**
24//! 3. **Remaining atoms stay undefined**
25//!
26//! # Gradient Treatment
27//!
28//! - True atoms: Normal probability and gradient computation
29//! - False atoms: Probability = 0, gradient = 0
30//! - Undefined atoms: Probability = 0, gradient = 0 (conservative)
31//!
32//! This matches ProbLog's behavior for non-stratified programs.
33//!
34//! # Integration
35//!
36//! WFS is invoked during provenance extraction when a non-monotone SCC is detected.
37//! It receives ground rules (after variable substitution) and returns the well-founded
38//! model with provenance formulas for true atoms.
39
40use crate::pir::{PirGraph, PirNodeId};
41use crate::provenance::Value;
42use std::collections::{HashMap, HashSet};
43use xlog_core::{Result, XlogError};
44
45/// Ground atom representation for WFS
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47pub struct WfsAtom {
48    pub predicate: String,
49    pub args: Vec<Value>,
50}
51
52impl WfsAtom {
53    /// Create a new WFS atom
54    pub fn new(predicate: impl Into<String>, args: Vec<Value>) -> Self {
55        Self {
56            predicate: predicate.into(),
57            args,
58        }
59    }
60}
61
62impl std::fmt::Display for WfsAtom {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(f, "{}(", self.predicate)?;
65        for (i, arg) in self.args.iter().enumerate() {
66            if i > 0 {
67                write!(f, ", ")?;
68            }
69            match arg {
70                Value::I64(v) => write!(f, "{}", v)?,
71                Value::F64(v) => write!(f, "{}", f64::from_bits(*v))?,
72                Value::Symbol(v) => write!(f, "sym{}", v)?,
73                Value::String(v) => write!(f, "\"{}\"", v)?,
74            }
75        }
76        write!(f, ")")
77    }
78}
79
80/// Three-valued truth value for WFS
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum TruthValue {
83    True,
84    False,
85    Undefined,
86}
87
88impl TruthValue {
89    /// Check if this is a definite value (not undefined)
90    pub fn is_defined(&self) -> bool {
91        matches!(self, TruthValue::True | TruthValue::False)
92    }
93}
94
95/// A ground literal in a rule body
96#[derive(Debug, Clone, PartialEq, Eq, Hash)]
97pub enum WfsLiteral {
98    /// Positive literal: atom must be true
99    Positive(WfsAtom),
100    /// Negative literal: atom must be false
101    Negative(WfsAtom),
102}
103
104impl WfsLiteral {
105    /// Get the underlying atom
106    pub fn atom(&self) -> &WfsAtom {
107        match self {
108            WfsLiteral::Positive(a) | WfsLiteral::Negative(a) => a,
109        }
110    }
111
112    /// Check if this is a positive literal
113    pub fn is_positive(&self) -> bool {
114        matches!(self, WfsLiteral::Positive(_))
115    }
116
117    /// Check if this is a negative literal
118    pub fn is_negative(&self) -> bool {
119        matches!(self, WfsLiteral::Negative(_))
120    }
121}
122
123/// A ground rule for WFS evaluation
124///
125/// A ground rule has no variables - all terms are concrete values.
126/// Each ground rule also carries a provenance formula for probabilistic tracking.
127#[derive(Debug, Clone)]
128pub struct WfsRule {
129    /// The head atom this rule derives
130    pub head: WfsAtom,
131    /// The body literals (all must be satisfied for rule to fire)
132    pub body: Vec<WfsLiteral>,
133    /// Provenance formula for this specific ground instance
134    /// This is the AND of all non-SCC body literal provenances
135    pub provenance: PirNodeId,
136}
137
138impl WfsRule {
139    /// Create a new ground rule
140    pub fn new(head: WfsAtom, body: Vec<WfsLiteral>, provenance: PirNodeId) -> Self {
141        Self {
142            head,
143            body,
144            provenance,
145        }
146    }
147
148    /// Check if rule body is satisfied under current interpretation
149    ///
150    /// For external atoms (not in SCC):
151    /// - Positive: must be in true_set (external facts provided)
152    /// - Negative: if not in true_set, assumed false (closed-world)
153    fn is_satisfied(
154        &self,
155        true_set: &HashSet<WfsAtom>,
156        false_set: &HashSet<WfsAtom>,
157        scc_atoms: &HashSet<WfsAtom>,
158    ) -> Option<bool> {
159        // Rule is satisfied if all body literals are satisfied
160        // Returns None if any literal is undefined
161        for lit in &self.body {
162            match lit {
163                WfsLiteral::Positive(atom) => {
164                    if false_set.contains(atom) {
165                        return Some(false);
166                    }
167                    if true_set.contains(atom) {
168                        continue; // Satisfied
169                    }
170                    // Not true and not false
171                    if scc_atoms.contains(atom) {
172                        return None; // SCC atom is undefined
173                    }
174                    // External atom not in true_set - will never become true
175                    return Some(false);
176                }
177                WfsLiteral::Negative(atom) => {
178                    if true_set.contains(atom) {
179                        return Some(false);
180                    }
181                    if false_set.contains(atom) {
182                        continue; // Satisfied (not X where X is false)
183                    }
184                    // Not true and not false
185                    if scc_atoms.contains(atom) {
186                        return None; // SCC atom is undefined
187                    }
188                    // External atom not in true_set - closed world says it's false
189                    // So "not external_atom" succeeds
190                    continue;
191                }
192            }
193        }
194        Some(true)
195    }
196
197    /// Check if rule body is definitely unsatisfiable.
198    ///
199    /// Reserved API: used for early pruning in more advanced WFS implementations.
200    #[allow(dead_code)] // reserved public API for future WFS optimization
201    pub fn is_definitely_unsatisfiable(
202        &self,
203        true_set: &HashSet<WfsAtom>,
204        false_set: &HashSet<WfsAtom>,
205    ) -> bool {
206        for lit in &self.body {
207            match lit {
208                WfsLiteral::Positive(atom) => {
209                    if false_set.contains(atom) {
210                        return true;
211                    }
212                }
213                WfsLiteral::Negative(atom) => {
214                    if true_set.contains(atom) {
215                        return true;
216                    }
217                }
218            }
219        }
220        false
221    }
222}
223
224/// Result of WFS evaluation for an SCC
225#[derive(Debug, Clone)]
226pub struct WfsResult {
227    /// Atoms known to be true with their provenance
228    pub true_set: HashMap<WfsAtom, PirNodeId>,
229    /// Atoms known to be false
230    pub false_set: HashSet<WfsAtom>,
231    // Atoms not in either set are undefined
232}
233
234impl WfsResult {
235    /// Create an empty WFS result
236    pub fn new() -> Self {
237        Self {
238            true_set: HashMap::new(),
239            false_set: HashSet::new(),
240        }
241    }
242
243    /// Get the truth value of an atom
244    pub fn truth_value(&self, atom: &WfsAtom) -> TruthValue {
245        if self.true_set.contains_key(atom) {
246            TruthValue::True
247        } else if self.false_set.contains(atom) {
248            TruthValue::False
249        } else {
250            TruthValue::Undefined
251        }
252    }
253
254    /// Get the provenance for a true atom
255    pub fn provenance(&self, atom: &WfsAtom) -> Option<PirNodeId> {
256        self.true_set.get(atom).copied()
257    }
258
259    /// Check if an atom is undefined
260    pub fn is_undefined(&self, atom: &WfsAtom) -> bool {
261        !self.true_set.contains_key(atom) && !self.false_set.contains(atom)
262    }
263
264    /// Get all undefined atoms from a set of atoms
265    pub fn undefined_atoms<'a>(
266        &self,
267        atoms: impl Iterator<Item = &'a WfsAtom>,
268    ) -> Vec<&'a WfsAtom> {
269        atoms.filter(|a| self.is_undefined(a)).collect()
270    }
271}
272
273impl Default for WfsResult {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279/// Configuration for Well-Founded Semantics evaluation.
280///
281/// Controls the convergence budget for the alternating fixed-point loop.
282#[derive(Debug, Clone)]
283#[non_exhaustive]
284pub struct WfsConfig {
285    /// Maximum iterations before giving up.
286    pub max_iterations: usize,
287}
288
289impl Default for WfsConfig {
290    fn default() -> Self {
291        Self {
292            max_iterations: 1000,
293        }
294    }
295}
296
297/// Context for WFS evaluation
298///
299/// This holds all the data needed during WFS computation.
300pub struct WfsContext<'a> {
301    /// Ground rules for the SCC
302    rules: &'a [WfsRule],
303    /// All atoms in the SCC (derived from rule heads)
304    scc_atoms: HashSet<WfsAtom>,
305    /// Rules indexed by head predicate for fast lookup
306    rules_by_head: HashMap<WfsAtom, Vec<usize>>,
307    /// PIR graph for building provenance
308    pir: &'a mut PirGraph,
309}
310
311impl<'a> WfsContext<'a> {
312    /// Create a new WFS context
313    fn new(rules: &'a [WfsRule], pir: &'a mut PirGraph) -> Self {
314        // Collect all head atoms
315        let mut scc_atoms = HashSet::new();
316        let mut rules_by_head: HashMap<WfsAtom, Vec<usize>> = HashMap::new();
317
318        for (i, rule) in rules.iter().enumerate() {
319            scc_atoms.insert(rule.head.clone());
320            rules_by_head.entry(rule.head.clone()).or_default().push(i);
321        }
322
323        Self {
324            rules,
325            scc_atoms,
326            rules_by_head,
327            pir,
328        }
329    }
330}
331
332/// Compute the unfounded set using a greatest fixed-point iteration.
333///
334/// An atom A is unfounded with respect to (T, F) if for every rule "A :- B":
335/// - B contains a positive literal that is in the unfounded set, or
336/// - B contains a literal that is known to be false given (T, F)
337///
338/// This computes the greatest fixed-point of the unfounded set operator.
339fn compute_unfounded_set(
340    ctx: &WfsContext,
341    true_set: &HashSet<WfsAtom>,
342    false_set: &HashSet<WfsAtom>,
343) -> HashSet<WfsAtom> {
344    // Start with all non-true atoms as potentially unfounded
345    let mut unfounded: HashSet<WfsAtom> = ctx
346        .scc_atoms
347        .iter()
348        .filter(|a| !true_set.contains(*a))
349        .cloned()
350        .collect();
351
352    // Greatest fixed-point: keep removing atoms that have a supporting rule
353    loop {
354        let mut changed = false;
355        let mut to_remove = Vec::new();
356
357        for atom in &unfounded {
358            // Check if any rule can support this atom
359            if let Some(rule_indices) = ctx.rules_by_head.get(atom) {
360                for &rule_idx in rule_indices {
361                    let rule = &ctx.rules[rule_idx];
362                    if rule_is_potentially_supporting(
363                        rule,
364                        true_set,
365                        false_set,
366                        &unfounded,
367                        &ctx.scc_atoms,
368                    ) {
369                        to_remove.push(atom.clone());
370                        changed = true;
371                        break;
372                    }
373                }
374            }
375        }
376
377        for atom in to_remove {
378            unfounded.remove(&atom);
379        }
380
381        if !changed {
382            break;
383        }
384    }
385
386    unfounded
387}
388
389/// Check if a rule can potentially support its head atom.
390///
391/// A rule is potentially supporting if all body literals can potentially be satisfied:
392/// - Positive literals must be either:
393///   - Already true, or
394///   - In the SCC and not in the unfounded set (could become true)
395/// - Negative literals must not be known true
396///
397/// External atoms (not in SCC) that are not already true cannot become true,
398/// so rules depending on them positively cannot fire.
399fn rule_is_potentially_supporting(
400    rule: &WfsRule,
401    true_set: &HashSet<WfsAtom>,
402    false_set: &HashSet<WfsAtom>,
403    unfounded: &HashSet<WfsAtom>,
404    scc_atoms: &HashSet<WfsAtom>,
405) -> bool {
406    for lit in &rule.body {
407        match lit {
408            WfsLiteral::Positive(atom) => {
409                // If the positive literal is known false, rule can't fire
410                if false_set.contains(atom) {
411                    return false;
412                }
413                // If already true, this literal is satisfied
414                if true_set.contains(atom) {
415                    continue;
416                }
417                // If the atom is in the SCC and in the unfounded set,
418                // it creates a circular dependency and can't support the head
419                if scc_atoms.contains(atom) {
420                    if unfounded.contains(atom) {
421                        return false;
422                    }
423                    // Atom is in SCC and not unfounded - could potentially become true
424                    continue;
425                }
426                // External atom (not in SCC) that is not true - can never become true
427                // So this rule can never fire
428                return false;
429            }
430            WfsLiteral::Negative(atom) => {
431                // If the negative literal is known true, rule can't fire
432                if true_set.contains(atom) {
433                    return false;
434                }
435            }
436        }
437    }
438    true
439}
440
441/// Derive consequences using the immediate consequence operator.
442///
443/// An atom becomes true if there exists a rule where:
444/// - All positive body literals are in true_set or are external facts
445/// - All negative body literals are in false_set
446///
447/// Returns new atoms to add to true_set with their provenances.
448fn derive_consequences(
449    ctx: &mut WfsContext,
450    true_set: &HashSet<WfsAtom>,
451    false_set: &HashSet<WfsAtom>,
452    existing_provenance: &HashMap<WfsAtom, PirNodeId>,
453) -> HashMap<WfsAtom, PirNodeId> {
454    let mut new_true: HashMap<WfsAtom, PirNodeId> = HashMap::new();
455
456    for rule in ctx.rules.iter() {
457        // Skip if head is already true or false
458        if true_set.contains(&rule.head) || false_set.contains(&rule.head) {
459            continue;
460        }
461
462        // Check if rule fires
463        if let Some(true) = rule.is_satisfied(true_set, false_set, &ctx.scc_atoms) {
464            // Build provenance: rule's base provenance AND all positive body provenances
465            let mut prov_parts = vec![rule.provenance];
466
467            for lit in &rule.body {
468                if let WfsLiteral::Positive(atom) = lit {
469                    if let Some(&atom_prov) = existing_provenance.get(atom) {
470                        prov_parts.push(atom_prov);
471                    }
472                    // External atoms (not in SCC) have their provenance already
473                    // folded into rule.provenance during grounding
474                }
475            }
476
477            let rule_prov = if prov_parts.len() == 1 {
478                prov_parts[0]
479            } else {
480                ctx.pir.and(prov_parts)
481            };
482
483            // Combine with any existing provenance for this atom (multiple rules)
484            let entry = new_true
485                .entry(rule.head.clone())
486                .or_insert_with(|| ctx.pir.const_false());
487            *entry = ctx.pir.or(vec![*entry, rule_prov]);
488        }
489    }
490
491    new_true
492}
493
494/// Evaluate a non-monotone SCC using Well-Founded Semantics.
495///
496/// This function takes pre-grounded rules for the SCC and computes the
497/// well-founded model using the alternating fixed-point algorithm.
498///
499/// # Arguments
500///
501/// * `rules` - Ground rules for atoms in this SCC
502/// * `pir` - PIR graph for building provenance formulas
503/// * `config` - Configuration options
504///
505/// # Returns
506///
507/// WfsResult containing true atoms (with provenance), false atoms, and
508/// implicitly undefined atoms (in neither set).
509///
510/// # Algorithm
511///
512/// The alternating fixed-point works by interleaving two operators:
513/// 1. **Unfounded set computation** (Φ): Find atoms with no possible support
514/// 2. **Consequence derivation** (Ψ): Find atoms that must be true
515///
516/// Starting from (T={}, F={}):
517/// - Compute unfounded set U = Φ(T, F)
518/// - Add U to F
519/// - Compute consequences C = Ψ(T, F)
520/// - Add C to T
521/// - Repeat until no changes
522pub fn evaluate_wfs_rules(
523    rules: &[WfsRule],
524    pir: &mut PirGraph,
525    config: &WfsConfig,
526) -> Result<WfsResult> {
527    if rules.is_empty() {
528        return Ok(WfsResult::new());
529    }
530
531    let mut ctx = WfsContext::new(rules, pir);
532
533    let mut true_set: HashSet<WfsAtom> = HashSet::new();
534    let mut true_provenance: HashMap<WfsAtom, PirNodeId> = HashMap::new();
535    let mut false_set: HashSet<WfsAtom> = HashSet::new();
536
537    for iteration in 0..config.max_iterations {
538        // Step 1: Compute unfounded set
539        let unfounded = compute_unfounded_set(&ctx, &true_set, &false_set);
540
541        // Add unfounded atoms to false set
542        let new_false: Vec<WfsAtom> = unfounded
543            .into_iter()
544            .filter(|a| !false_set.contains(a))
545            .collect();
546        let new_false_count = new_false.len();
547        false_set.extend(new_false);
548
549        // Step 2: Derive consequences
550        let new_true = derive_consequences(&mut ctx, &true_set, &false_set, &true_provenance);
551        let new_true_count = new_true.len();
552
553        // Add new true atoms
554        for (atom, prov) in new_true {
555            if !true_set.contains(&atom) {
556                true_set.insert(atom.clone());
557                // Combine provenance if atom becomes true via multiple rules
558                let entry = true_provenance
559                    .entry(atom)
560                    .or_insert_with(|| ctx.pir.const_false());
561                *entry = ctx.pir.or(vec![*entry, prov]);
562            }
563        }
564
565        // Check for fixed point
566        if new_false_count == 0 && new_true_count == 0 {
567            break;
568        }
569
570        if iteration == config.max_iterations - 1 {
571            return Err(XlogError::Execution(format!(
572                "WFS evaluation did not converge after {} iterations",
573                config.max_iterations
574            )));
575        }
576    }
577
578    Ok(WfsResult {
579        true_set: true_provenance,
580        false_set,
581    })
582}
583
584/// Evaluate WFS with provided ground rules.
585///
586/// This is the main entry point for WFS evaluation during provenance extraction.
587/// The caller (provenance extractor) is responsible for:
588/// 1. Detecting non-monotone SCCs
589/// 2. Grounding the rules in those SCCs
590/// 3. Providing the ground rules with their provenances
591pub fn evaluate_wfs_with_rules(rules: Vec<WfsRule>, pir: &mut PirGraph) -> Result<WfsResult> {
592    evaluate_wfs_rules(&rules, pir, &WfsConfig::default())
593}
594
595// --- Test-only helpers below ---
596// These legacy interfaces use predicate names (not ground rules) and are only
597// exercised by the unit tests in this module. Gating behind #[cfg(test)] to
598// avoid dead_code warnings in library builds.
599
600#[cfg(test)]
601fn evaluate_wfs_scc_with_config(
602    scc_predicates: &[String],
603    pir: &mut PirGraph,
604    config: &WfsConfig,
605) -> Result<WfsResult> {
606    if scc_predicates.is_empty() {
607        return Ok(WfsResult::new());
608    }
609
610    let false_set: HashSet<WfsAtom> = scc_predicates
611        .iter()
612        .map(|p| WfsAtom::new(p.clone(), vec![]))
613        .collect();
614
615    let _ = config;
616    let _ = pir;
617
618    Ok(WfsResult {
619        true_set: HashMap::new(),
620        false_set,
621    })
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use crate::pir::LeafId;
628
629    /// Helper to create a simple propositional atom (no args)
630    fn prop(name: &str) -> WfsAtom {
631        WfsAtom::new(name, vec![])
632    }
633
634    /// Helper to create a ground atom with integer args
635    fn atom(name: &str, args: &[i64]) -> WfsAtom {
636        WfsAtom::new(name, args.iter().map(|&i| Value::I64(i)).collect())
637    }
638
639    #[test]
640    fn test_wfs_config_default() {
641        let config = WfsConfig::default();
642        assert_eq!(config.max_iterations, 1000);
643    }
644
645    #[test]
646    fn test_wfs_result_default() {
647        let result = WfsResult::default();
648        assert!(result.true_set.is_empty());
649        assert!(result.false_set.is_empty());
650    }
651
652    #[test]
653    fn test_wfs_result_truth_value() {
654        let mut result = WfsResult::new();
655        let atom = prop("p");
656
657        // Initially undefined
658        assert_eq!(result.truth_value(&atom), TruthValue::Undefined);
659
660        // After adding to false_set
661        result.false_set.insert(atom.clone());
662        assert_eq!(result.truth_value(&atom), TruthValue::False);
663
664        // After moving to true_set
665        result.false_set.remove(&atom);
666        let mut pir = PirGraph::new();
667        let node_id = pir.lit(LeafId::new(0));
668        result.true_set.insert(atom.clone(), node_id);
669        assert_eq!(result.truth_value(&atom), TruthValue::True);
670    }
671
672    #[test]
673    fn test_wfs_atom_equality() {
674        let atom1 = WfsAtom::new("p", vec![Value::I64(1)]);
675        let atom2 = WfsAtom::new("p", vec![Value::I64(1)]);
676        let atom3 = WfsAtom::new("p", vec![Value::I64(2)]);
677
678        assert_eq!(atom1, atom2);
679        assert_ne!(atom1, atom3);
680    }
681
682    #[test]
683    fn test_wfs_empty_rules() {
684        let mut pir = PirGraph::new();
685        let result = evaluate_wfs_rules(&[], &mut pir, &WfsConfig::default()).unwrap();
686        assert!(result.true_set.is_empty());
687        assert!(result.false_set.is_empty());
688    }
689
690    #[test]
691    fn test_wfs_simple_fact() {
692        // Test: p. (fact)
693        // p :- (empty body, always true)
694        let mut pir = PirGraph::new();
695        let const_true = pir.const_true();
696
697        let rules = vec![WfsRule::new(prop("p"), vec![], const_true)];
698
699        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
700
701        assert_eq!(result.truth_value(&prop("p")), TruthValue::True);
702        assert!(result.provenance(&prop("p")).is_some());
703    }
704
705    #[test]
706    fn test_wfs_simple_negation() {
707        // Test: p :- not q. q :- not p.
708        // Classic non-stratifiable program
709        // Under WFS, both p and q are undefined
710        let mut pir = PirGraph::new();
711        let const_true = pir.const_true();
712
713        let rules = vec![
714            WfsRule::new(prop("p"), vec![WfsLiteral::Negative(prop("q"))], const_true),
715            WfsRule::new(prop("q"), vec![WfsLiteral::Negative(prop("p"))], const_true),
716        ];
717
718        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
719
720        // Both should be undefined (not in true_set, not in false_set)
721        assert_eq!(result.truth_value(&prop("p")), TruthValue::Undefined);
722        assert_eq!(result.truth_value(&prop("q")), TruthValue::Undefined);
723    }
724
725    #[test]
726    fn test_wfs_asymmetric_negation() {
727        // Test: p :- not q. q.
728        // q is a fact, so it's true. Therefore p is false (not q is false).
729        let mut pir = PirGraph::new();
730        let const_true = pir.const_true();
731
732        let rules = vec![
733            WfsRule::new(prop("p"), vec![WfsLiteral::Negative(prop("q"))], const_true),
734            WfsRule::new(prop("q"), vec![], const_true),
735        ];
736
737        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
738
739        assert_eq!(result.truth_value(&prop("q")), TruthValue::True);
740        assert_eq!(result.truth_value(&prop("p")), TruthValue::False);
741    }
742
743    #[test]
744    fn test_wfs_three_way_cycle() {
745        // Test: p :- not q. q :- not r. r :- not p.
746        // This creates a 3-way cycle where all are undefined
747        let mut pir = PirGraph::new();
748        let const_true = pir.const_true();
749
750        let rules = vec![
751            WfsRule::new(prop("p"), vec![WfsLiteral::Negative(prop("q"))], const_true),
752            WfsRule::new(prop("q"), vec![WfsLiteral::Negative(prop("r"))], const_true),
753            WfsRule::new(prop("r"), vec![WfsLiteral::Negative(prop("p"))], const_true),
754        ];
755
756        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
757
758        // All three are in a cycle through negation, so all undefined
759        assert_eq!(result.truth_value(&prop("p")), TruthValue::Undefined);
760        assert_eq!(result.truth_value(&prop("q")), TruthValue::Undefined);
761        assert_eq!(result.truth_value(&prop("r")), TruthValue::Undefined);
762    }
763
764    #[test]
765    fn test_wfs_win_lose() {
766        // Classic example: win(X) :- move(X,Y), not win(Y).
767        // Grounded for a simple two-node game: move(1,2). move(2,1).
768        //
769        // win(1) :- move(1,2), not win(2).
770        // win(2) :- move(2,1), not win(1).
771        //
772        // Both win(1) and win(2) are undefined (mutual negation through positive deps)
773        let mut pir = PirGraph::new();
774        let const_true = pir.const_true();
775
776        // We model move as already resolved (external to SCC)
777        let rules = vec![
778            WfsRule::new(
779                atom("win", &[1]),
780                vec![WfsLiteral::Negative(atom("win", &[2]))],
781                const_true, // move(1,2) is true
782            ),
783            WfsRule::new(
784                atom("win", &[2]),
785                vec![WfsLiteral::Negative(atom("win", &[1]))],
786                const_true, // move(2,1) is true
787            ),
788        ];
789
790        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
791
792        // Both win(1) and win(2) are undefined
793        assert_eq!(
794            result.truth_value(&atom("win", &[1])),
795            TruthValue::Undefined
796        );
797        assert_eq!(
798            result.truth_value(&atom("win", &[2])),
799            TruthValue::Undefined
800        );
801    }
802
803    #[test]
804    fn test_wfs_win_with_base_case() {
805        // win(X) :- move(X,Y), not win(Y).
806        // Grounded: move(1,2). (no move from 2)
807        //
808        // This demonstrates that atoms NOT in the SCC (external atoms) are treated
809        // as having their truth value determined by the external context.
810        //
811        // In a real integration:
812        // - win(2) would have no rule, so during grounding it wouldn't be added
813        // - The "not win(2)" would be evaluated against the external store
814        // - If win(2) doesn't exist externally, closed-world assumes it's false
815        //
816        // For this isolated test, we simulate the scenario where:
817        // - win(2) has an empty rule set (meaning it's unfounded)
818        let mut pir = PirGraph::new();
819        let const_true = pir.const_true();
820
821        // We need to ensure win(2) is tracked as an SCC atom that gets resolved
822        // The simplest way: include a "failing" rule for win(2) that can never fire
823        // Or: include win(2) with no rules (it will be unfounded -> false)
824
825        // To properly test this, we add win(2) as a head that depends on something impossible
826        // This makes win(2) unfounded and thus false
827        let rules = vec![
828            WfsRule::new(
829                atom("win", &[1]),
830                vec![WfsLiteral::Negative(atom("win", &[2]))],
831                const_true,
832            ),
833            // win(2) :- win(3). but win(3) doesn't exist, so win(2) is unfounded
834            WfsRule::new(
835                atom("win", &[2]),
836                vec![WfsLiteral::Positive(atom("win", &[3]))],
837                const_true,
838            ),
839        ];
840
841        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
842
843        // win(3) is not in SCC (no head), so the positive dependency on it fails
844        // This means win(2)'s only rule can't fire, making win(2) unfounded -> false
845        // With win(2) false, "not win(2)" succeeds, so win(1) becomes true
846        assert_eq!(result.truth_value(&atom("win", &[1])), TruthValue::True);
847        assert_eq!(result.truth_value(&atom("win", &[2])), TruthValue::False);
848    }
849
850    #[test]
851    fn test_wfs_chain_with_grounding() {
852        // Test a longer chain that resolves:
853        // a. b :- not a. c :- not b.
854        //
855        // a is a fact (true)
856        // b :- not a fails (a is true, so not a is false) -> b is false
857        // c :- not b succeeds (b is false, so not b is true) -> c is true
858        let mut pir = PirGraph::new();
859        let const_true = pir.const_true();
860
861        let rules = vec![
862            WfsRule::new(prop("a"), vec![], const_true),
863            WfsRule::new(prop("b"), vec![WfsLiteral::Negative(prop("a"))], const_true),
864            WfsRule::new(prop("c"), vec![WfsLiteral::Negative(prop("b"))], const_true),
865        ];
866
867        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
868
869        assert_eq!(result.truth_value(&prop("a")), TruthValue::True);
870        assert_eq!(result.truth_value(&prop("b")), TruthValue::False);
871        assert_eq!(result.truth_value(&prop("c")), TruthValue::True);
872    }
873
874    #[test]
875    fn test_wfs_multiple_rules_same_head() {
876        // Test: p :- q. p :- not r. q. r.
877        // p can be derived from q (which is true)
878        // p's second rule fails because r is true
879        // Final: p is true (via first rule), q is true, r is true
880        let mut pir = PirGraph::new();
881        let const_true = pir.const_true();
882
883        let rules = vec![
884            WfsRule::new(prop("q"), vec![], const_true),
885            WfsRule::new(prop("r"), vec![], const_true),
886            WfsRule::new(prop("p"), vec![WfsLiteral::Positive(prop("q"))], const_true),
887            WfsRule::new(prop("p"), vec![WfsLiteral::Negative(prop("r"))], const_true),
888        ];
889
890        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
891
892        assert_eq!(result.truth_value(&prop("q")), TruthValue::True);
893        assert_eq!(result.truth_value(&prop("r")), TruthValue::True);
894        assert_eq!(result.truth_value(&prop("p")), TruthValue::True);
895    }
896
897    #[test]
898    fn test_wfs_provenance_tracking() {
899        // Test that provenance is correctly tracked
900        // p :- (with leaf probability)
901        let mut pir = PirGraph::new();
902        let leaf = pir.lit(LeafId::new(42));
903
904        let rules = vec![WfsRule::new(prop("p"), vec![], leaf)];
905
906        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
907
908        let prov = result.provenance(&prop("p"));
909        assert!(prov.is_some());
910        // The provenance is built through OR combination, so it may not be the exact same node
911        // but it should exist and have a valid ID
912        assert!(prov.unwrap().as_u32() > 0);
913    }
914
915    #[test]
916    fn test_wfs_combined_provenance() {
917        // Test: p :- q. where q has a probabilistic provenance
918        // p's provenance should combine the rule's provenance with q's provenance
919        let mut pir = PirGraph::new();
920        let leaf_q = pir.lit(LeafId::new(1));
921        let const_true = pir.const_true();
922
923        let rules = vec![
924            WfsRule::new(prop("q"), vec![], leaf_q),
925            WfsRule::new(prop("p"), vec![WfsLiteral::Positive(prop("q"))], const_true),
926        ];
927
928        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
929
930        assert_eq!(result.truth_value(&prop("q")), TruthValue::True);
931        assert_eq!(result.truth_value(&prop("p")), TruthValue::True);
932
933        // p's provenance should be AND(const_true, leaf_q)
934        // which simplifies to leaf_q in a smarter builder
935        let p_prov = result.provenance(&prop("p")).unwrap();
936        // The exact node ID depends on builder implementation
937        assert!(p_prov.as_u32() > 0);
938    }
939
940    #[test]
941    fn test_wfs_no_rules_for_predicate() {
942        // When predicates have no rules, they should be unfounded (false)
943        let result = evaluate_wfs_scc_with_config(
944            &["p".to_string(), "q".to_string()],
945            &mut PirGraph::new(),
946            &WfsConfig::default(),
947        )
948        .unwrap();
949
950        assert_eq!(result.truth_value(&prop("p")), TruthValue::False);
951        assert_eq!(result.truth_value(&prop("q")), TruthValue::False);
952    }
953
954    #[test]
955    fn test_wfs_positive_cycle() {
956        // Test positive cycle (not through negation): p :- q. q :- p.
957        // Without any base case, both are unfounded and thus false.
958        let mut pir = PirGraph::new();
959        let const_true = pir.const_true();
960
961        let rules = vec![
962            WfsRule::new(prop("p"), vec![WfsLiteral::Positive(prop("q"))], const_true),
963            WfsRule::new(prop("q"), vec![WfsLiteral::Positive(prop("p"))], const_true),
964        ];
965
966        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
967
968        // Pure positive cycle with no external support -> both unfounded -> false
969        assert_eq!(result.truth_value(&prop("p")), TruthValue::False);
970        assert_eq!(result.truth_value(&prop("q")), TruthValue::False);
971    }
972
973    #[test]
974    fn test_wfs_positive_cycle_with_base() {
975        // Test: p :- q. q :- p. p.
976        // p has a fact, so p is true, q depends on p, so q is true
977        let mut pir = PirGraph::new();
978        let const_true = pir.const_true();
979
980        let rules = vec![
981            WfsRule::new(prop("p"), vec![], const_true), // Base fact
982            WfsRule::new(prop("p"), vec![WfsLiteral::Positive(prop("q"))], const_true),
983            WfsRule::new(prop("q"), vec![WfsLiteral::Positive(prop("p"))], const_true),
984        ];
985
986        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
987
988        assert_eq!(result.truth_value(&prop("p")), TruthValue::True);
989        assert_eq!(result.truth_value(&prop("q")), TruthValue::True);
990    }
991
992    #[test]
993    fn test_wfs_mixed_dependencies() {
994        // Complex test: a. b :- a. c :- not b. d :- c, not e. e :- not d.
995        //
996        // a is true (fact)
997        // b is true (depends on a)
998        // c is false (not b fails because b is true)
999        // Now d :- c, not e and e :- not d
1000        // c is false, so d can't fire from that path
1001        // d has no way to be true, so d is unfounded -> false
1002        // e :- not d fires (d is false) -> e is true
1003        let mut pir = PirGraph::new();
1004        let const_true = pir.const_true();
1005
1006        let rules = vec![
1007            WfsRule::new(prop("a"), vec![], const_true),
1008            WfsRule::new(prop("b"), vec![WfsLiteral::Positive(prop("a"))], const_true),
1009            WfsRule::new(prop("c"), vec![WfsLiteral::Negative(prop("b"))], const_true),
1010            WfsRule::new(
1011                prop("d"),
1012                vec![
1013                    WfsLiteral::Positive(prop("c")),
1014                    WfsLiteral::Negative(prop("e")),
1015                ],
1016                const_true,
1017            ),
1018            WfsRule::new(prop("e"), vec![WfsLiteral::Negative(prop("d"))], const_true),
1019        ];
1020
1021        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
1022
1023        assert_eq!(result.truth_value(&prop("a")), TruthValue::True);
1024        assert_eq!(result.truth_value(&prop("b")), TruthValue::True);
1025        assert_eq!(result.truth_value(&prop("c")), TruthValue::False);
1026        assert_eq!(result.truth_value(&prop("d")), TruthValue::False);
1027        assert_eq!(result.truth_value(&prop("e")), TruthValue::True);
1028    }
1029
1030    #[test]
1031    fn test_wfs_undefined_atoms_method() {
1032        let mut result = WfsResult::new();
1033        let p = prop("p");
1034        let q = prop("q");
1035        let r = prop("r");
1036
1037        result.true_set.insert(p.clone(), PirNodeId::from(0));
1038        result.false_set.insert(q.clone());
1039        // r is undefined
1040
1041        let atoms = vec![&p, &q, &r];
1042        let undefined = result.undefined_atoms(atoms.into_iter());
1043
1044        assert_eq!(undefined.len(), 1);
1045        assert_eq!(undefined[0], &r);
1046    }
1047
1048    // Helper for PirNodeId construction in tests
1049    impl From<u32> for PirNodeId {
1050        fn from(v: u32) -> Self {
1051            // This is just for testing; normally PirNodeId comes from PirGraph
1052            // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
1053            unsafe { std::mem::transmute(v) }
1054        }
1055    }
1056
1057    #[test]
1058    fn test_wfs_even_odd_cycle() {
1059        // Classic even/odd example with self-negation:
1060        // even(0).
1061        // even(X) :- succ(Y, X), odd(Y).
1062        // odd(X) :- succ(Y, X), even(Y).
1063        //
1064        // Grounded for 0, 1, 2:
1065        // even(0) is a fact
1066        // even(1) :- odd(0). odd(0) :- even(0). so odd(0) is true
1067        // therefore even(1) is true
1068        // etc.
1069        //
1070        // This tests positive recursion through multiple predicates
1071        let mut pir = PirGraph::new();
1072        let const_true = pir.const_true();
1073
1074        let rules = vec![
1075            // even(0) is a fact
1076            WfsRule::new(atom("even", &[0]), vec![], const_true),
1077            // odd(0) :- even(0)  (succ(0, 0+1) doesn't exist, so this is from even(0))
1078            // Actually let's simplify: odd(N) :- even(N-1) for N > 0
1079            // odd(1) :- even(0)
1080            WfsRule::new(
1081                atom("odd", &[1]),
1082                vec![WfsLiteral::Positive(atom("even", &[0]))],
1083                const_true,
1084            ),
1085            // even(2) :- odd(1)
1086            WfsRule::new(
1087                atom("even", &[2]),
1088                vec![WfsLiteral::Positive(atom("odd", &[1]))],
1089                const_true,
1090            ),
1091            // odd(3) :- even(2)
1092            WfsRule::new(
1093                atom("odd", &[3]),
1094                vec![WfsLiteral::Positive(atom("even", &[2]))],
1095                const_true,
1096            ),
1097        ];
1098
1099        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
1100
1101        assert_eq!(result.truth_value(&atom("even", &[0])), TruthValue::True);
1102        assert_eq!(result.truth_value(&atom("odd", &[1])), TruthValue::True);
1103        assert_eq!(result.truth_value(&atom("even", &[2])), TruthValue::True);
1104        assert_eq!(result.truth_value(&atom("odd", &[3])), TruthValue::True);
1105    }
1106
1107    #[test]
1108    fn test_wfs_default_logic() {
1109        // Default logic example: bird(X) :- penguin(X). flies(X) :- bird(X), not ab(X). ab(X) :- penguin(X).
1110        // penguin(tweety).
1111        //
1112        // Grounded:
1113        // bird(tweety) :- penguin(tweety). (penguin is external/fact)
1114        // flies(tweety) :- bird(tweety), not ab(tweety).
1115        // ab(tweety) :- penguin(tweety). (penguin is external/fact)
1116        //
1117        // If penguin(tweety) is true (external):
1118        // - bird(tweety) becomes true
1119        // - ab(tweety) becomes true
1120        // - flies(tweety) fails because ab(tweety) is true
1121        let mut pir = PirGraph::new();
1122        let const_true = pir.const_true();
1123
1124        // Simulate penguin(tweety) being true externally by including it as a fact in provenance
1125        let rules = vec![
1126            // penguin(tweety) is a fact (external, but we include it for completeness)
1127            WfsRule::new(atom("penguin", &[1]), vec![], const_true), // tweety = 1
1128            // bird(tweety) :- penguin(tweety)
1129            WfsRule::new(
1130                atom("bird", &[1]),
1131                vec![WfsLiteral::Positive(atom("penguin", &[1]))],
1132                const_true,
1133            ),
1134            // ab(tweety) :- penguin(tweety)
1135            WfsRule::new(
1136                atom("ab", &[1]),
1137                vec![WfsLiteral::Positive(atom("penguin", &[1]))],
1138                const_true,
1139            ),
1140            // flies(tweety) :- bird(tweety), not ab(tweety)
1141            WfsRule::new(
1142                atom("flies", &[1]),
1143                vec![
1144                    WfsLiteral::Positive(atom("bird", &[1])),
1145                    WfsLiteral::Negative(atom("ab", &[1])),
1146                ],
1147                const_true,
1148            ),
1149        ];
1150
1151        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
1152
1153        assert_eq!(result.truth_value(&atom("penguin", &[1])), TruthValue::True);
1154        assert_eq!(result.truth_value(&atom("bird", &[1])), TruthValue::True);
1155        assert_eq!(result.truth_value(&atom("ab", &[1])), TruthValue::True);
1156        assert_eq!(result.truth_value(&atom("flies", &[1])), TruthValue::False);
1157    }
1158
1159    #[test]
1160    fn test_wfs_non_ground_birds() {
1161        // Test multiple individuals with the bird/flies/ab pattern
1162        // bird(X) :- penguin(X). bird(X) :- eagle(X).
1163        // flies(X) :- bird(X), not ab(X).
1164        // ab(X) :- penguin(X).
1165        //
1166        // Facts: penguin(tweety). eagle(sam).
1167        //
1168        // Results:
1169        // - tweety: bird, ab, not flies
1170        // - sam: bird, not ab, flies
1171        let mut pir = PirGraph::new();
1172        let const_true = pir.const_true();
1173
1174        let rules = vec![
1175            // Facts
1176            WfsRule::new(atom("penguin", &[1]), vec![], const_true), // tweety = 1
1177            WfsRule::new(atom("eagle", &[2]), vec![], const_true),   // sam = 2
1178            // bird(X) :- penguin(X)
1179            WfsRule::new(
1180                atom("bird", &[1]),
1181                vec![WfsLiteral::Positive(atom("penguin", &[1]))],
1182                const_true,
1183            ),
1184            // bird(X) :- eagle(X)
1185            WfsRule::new(
1186                atom("bird", &[2]),
1187                vec![WfsLiteral::Positive(atom("eagle", &[2]))],
1188                const_true,
1189            ),
1190            // ab(X) :- penguin(X)
1191            WfsRule::new(
1192                atom("ab", &[1]),
1193                vec![WfsLiteral::Positive(atom("penguin", &[1]))],
1194                const_true,
1195            ),
1196            // flies(tweety) :- bird(tweety), not ab(tweety)
1197            WfsRule::new(
1198                atom("flies", &[1]),
1199                vec![
1200                    WfsLiteral::Positive(atom("bird", &[1])),
1201                    WfsLiteral::Negative(atom("ab", &[1])),
1202                ],
1203                const_true,
1204            ),
1205            // flies(sam) :- bird(sam), not ab(sam)
1206            // Note: ab(sam) has no rule, so it's unfounded and false
1207            WfsRule::new(
1208                atom("flies", &[2]),
1209                vec![
1210                    WfsLiteral::Positive(atom("bird", &[2])),
1211                    WfsLiteral::Negative(atom("ab", &[2])),
1212                ],
1213                const_true,
1214            ),
1215        ];
1216
1217        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
1218
1219        // tweety (1)
1220        assert_eq!(result.truth_value(&atom("penguin", &[1])), TruthValue::True);
1221        assert_eq!(result.truth_value(&atom("bird", &[1])), TruthValue::True);
1222        assert_eq!(result.truth_value(&atom("ab", &[1])), TruthValue::True);
1223        assert_eq!(result.truth_value(&atom("flies", &[1])), TruthValue::False);
1224
1225        // sam (2)
1226        assert_eq!(result.truth_value(&atom("eagle", &[2])), TruthValue::True);
1227        assert_eq!(result.truth_value(&atom("bird", &[2])), TruthValue::True);
1228        // ab(2) has no rule, so it's not in the SCC atoms and treated as external false
1229        // Actually, ab(2) is referenced in a negative literal but has no rule
1230        // Let's check what happens
1231        assert_eq!(result.truth_value(&atom("flies", &[2])), TruthValue::True);
1232    }
1233
1234    #[test]
1235    fn test_wfs_probabilistic_provenance_or() {
1236        // Test that provenance correctly combines when multiple rules derive same atom
1237        // p :- q. p :- r. q. r.
1238        // p's provenance should be OR(q_prov, r_prov)
1239        let mut pir = PirGraph::new();
1240        let leaf_q = pir.lit(LeafId::new(1));
1241        let leaf_r = pir.lit(LeafId::new(2));
1242
1243        let rules = vec![
1244            WfsRule::new(prop("q"), vec![], leaf_q),
1245            WfsRule::new(prop("r"), vec![], leaf_r),
1246            WfsRule::new(
1247                prop("p"),
1248                vec![WfsLiteral::Positive(prop("q"))],
1249                pir.const_true(),
1250            ),
1251            WfsRule::new(
1252                prop("p"),
1253                vec![WfsLiteral::Positive(prop("r"))],
1254                pir.const_true(),
1255            ),
1256        ];
1257
1258        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
1259
1260        assert_eq!(result.truth_value(&prop("p")), TruthValue::True);
1261        assert_eq!(result.truth_value(&prop("q")), TruthValue::True);
1262        assert_eq!(result.truth_value(&prop("r")), TruthValue::True);
1263
1264        // p's provenance should exist and be a combination
1265        let p_prov = result.provenance(&prop("p")).unwrap();
1266        // The exact structure depends on builder, but it should exist
1267        assert!(p_prov.as_u32() > 0);
1268    }
1269
1270    #[test]
1271    fn test_wfs_stable_model_unique() {
1272        // Test a program with a unique stable model:
1273        // a :- not b, not c. b :- not a, not c. c :- not a, not b. d.
1274        //
1275        // With d as a fact, this doesn't change the a/b/c cycle.
1276        // Under WFS, a, b, c are all undefined (three-way cycle).
1277        let mut pir = PirGraph::new();
1278        let const_true = pir.const_true();
1279
1280        let rules = vec![
1281            WfsRule::new(prop("d"), vec![], const_true),
1282            WfsRule::new(
1283                prop("a"),
1284                vec![
1285                    WfsLiteral::Negative(prop("b")),
1286                    WfsLiteral::Negative(prop("c")),
1287                ],
1288                const_true,
1289            ),
1290            WfsRule::new(
1291                prop("b"),
1292                vec![
1293                    WfsLiteral::Negative(prop("a")),
1294                    WfsLiteral::Negative(prop("c")),
1295                ],
1296                const_true,
1297            ),
1298            WfsRule::new(
1299                prop("c"),
1300                vec![
1301                    WfsLiteral::Negative(prop("a")),
1302                    WfsLiteral::Negative(prop("b")),
1303                ],
1304                const_true,
1305            ),
1306        ];
1307
1308        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
1309
1310        assert_eq!(result.truth_value(&prop("d")), TruthValue::True);
1311        // All three are in a cycle, so undefined under WFS
1312        assert_eq!(result.truth_value(&prop("a")), TruthValue::Undefined);
1313        assert_eq!(result.truth_value(&prop("b")), TruthValue::Undefined);
1314        assert_eq!(result.truth_value(&prop("c")), TruthValue::Undefined);
1315    }
1316
1317    #[test]
1318    fn test_wfs_hamiltonian_cycle_like() {
1319        // A pattern similar to what appears in Hamiltonian cycle encodings:
1320        // in(1,2) :- edge(1,2), not out(1,2).
1321        // out(1,2) :- edge(1,2), not in(1,2).
1322        //
1323        // If edge(1,2) is true, both in(1,2) and out(1,2) are undefined
1324        // (classic non-stratified choice)
1325        let mut pir = PirGraph::new();
1326        let const_true = pir.const_true();
1327
1328        let rules = vec![
1329            // edge(1,2) as a fact
1330            WfsRule::new(atom("edge", &[1, 2]), vec![], const_true),
1331            // in(1,2) :- edge(1,2), not out(1,2)
1332            WfsRule::new(
1333                atom("in", &[1, 2]),
1334                vec![
1335                    WfsLiteral::Positive(atom("edge", &[1, 2])),
1336                    WfsLiteral::Negative(atom("out", &[1, 2])),
1337                ],
1338                const_true,
1339            ),
1340            // out(1,2) :- edge(1,2), not in(1,2)
1341            WfsRule::new(
1342                atom("out", &[1, 2]),
1343                vec![
1344                    WfsLiteral::Positive(atom("edge", &[1, 2])),
1345                    WfsLiteral::Negative(atom("in", &[1, 2])),
1346                ],
1347                const_true,
1348            ),
1349        ];
1350
1351        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
1352
1353        assert_eq!(result.truth_value(&atom("edge", &[1, 2])), TruthValue::True);
1354        // Both in and out are in a cycle through negation
1355        assert_eq!(
1356            result.truth_value(&atom("in", &[1, 2])),
1357            TruthValue::Undefined
1358        );
1359        assert_eq!(
1360            result.truth_value(&atom("out", &[1, 2])),
1361            TruthValue::Undefined
1362        );
1363    }
1364
1365    #[test]
1366    fn test_wfs_partial_grounding() {
1367        // Test that atoms with no rules at all are not tracked
1368        // (they're external and handled by the provenance extractor)
1369        let mut pir = PirGraph::new();
1370        let const_true = pir.const_true();
1371
1372        // p :- q. (q has no rule, so it's external)
1373        let rules = vec![WfsRule::new(
1374            prop("p"),
1375            vec![WfsLiteral::Positive(prop("q"))],
1376            const_true,
1377        )];
1378
1379        let result = evaluate_wfs_rules(&rules, &mut pir, &WfsConfig::default()).unwrap();
1380
1381        // p depends positively on q, which is external and not true
1382        // Therefore p's rule can never fire, making p unfounded and false
1383        assert_eq!(result.truth_value(&prop("p")), TruthValue::False);
1384        // q is not tracked (not an SCC atom)
1385        assert_eq!(result.truth_value(&prop("q")), TruthValue::Undefined);
1386    }
1387}