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