Skip to main content

argumentation/aspic/
argument.rs

1//! Argument tree construction from knowledge base and rule set.
2//!
3//! An ASPIC+ argument is a tree: leaves are premises, internal nodes are
4//! rule applications. We construct arguments by forward chaining from the
5//! knowledge base, applying rules whose premises are all derived.
6//!
7//! **Cyclic rule sets are rejected up front.** Forward chaining over rule
8//! cycles (`p ⇒ p`, or `p ⇒ q, q ⇒ p`) produces infinite argument sequences
9//! with fresh ids at each iteration: even though each argument tree has a
10//! unique `(rule_id, sub_args)` tuple and the inner `already_exists` check
11//! catches duplicates *at the same depth*, a genuine cycle keeps producing
12//! deeper trees indefinitely (`A0: p → A1: p via rule on A0 → A2: p via
13//! rule on A1 → ...`). The `already_exists` guard does not save us here,
14//! so we detect rule-dependency cycles by DFS before chaining starts and
15//! return [`crate::Error::Aspic`] if any are found.
16
17use super::kb::{KnowledgeBase, Premise};
18use super::language::Literal;
19use super::rules::{Rule, RuleId};
20use std::collections::{HashMap, HashSet};
21
22/// A unique argument id within a `StructuredSystem`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub struct ArgumentId(pub usize);
25
26impl std::fmt::Display for ArgumentId {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(f, "A{}", self.0)
29    }
30}
31
32/// An ASPIC+ argument.
33#[derive(Debug, Clone)]
34pub struct Argument {
35    /// This argument's id.
36    pub id: ArgumentId,
37    /// The conclusion of the argument (the literal it supports).
38    pub conclusion: Literal,
39    /// The origin of the argument: a premise (leaf) or a rule application.
40    pub origin: Origin,
41    /// The ids of sub-arguments this argument depends on.
42    pub sub_arguments: Vec<ArgumentId>,
43}
44
45/// How an argument was constructed.
46#[derive(Debug, Clone)]
47pub enum Origin {
48    /// A leaf argument: a premise taken directly from the knowledge base.
49    Premise(Premise),
50    /// An internal argument: a rule applied to sub-arguments.
51    RuleApplication(RuleId),
52}
53
54impl Argument {
55    /// Whether this argument is a premise leaf.
56    pub fn is_premise(&self) -> bool {
57        matches!(self.origin, Origin::Premise(_))
58    }
59
60    /// Whether this argument ends in a defeasible rule application.
61    pub fn top_rule_is_defeasible(&self, rules: &[Rule]) -> bool {
62        if let Origin::RuleApplication(rid) = &self.origin {
63            rules
64                .iter()
65                .find(|r| r.id == *rid)
66                .map(|r| r.is_defeasible())
67                .unwrap_or(false)
68        } else {
69            false
70        }
71    }
72}
73
74/// Detect cyclic rule dependencies via DFS.
75///
76/// Builds a directed graph where `r -> s` iff `r`'s premises contain `s`'s
77/// conclusion (i.e., applying `s` is a prerequisite for applying `r`).
78/// Returns `true` if the graph contains a cycle.
79fn rule_set_has_cycle(rules: &[Rule]) -> bool {
80    let mut deps: HashMap<RuleId, Vec<RuleId>> = HashMap::new();
81    for r in rules {
82        let r_deps: Vec<RuleId> = rules
83            .iter()
84            .filter(|s| r.premises.contains(&s.conclusion))
85            .map(|s| s.id)
86            .collect();
87        deps.insert(r.id, r_deps);
88    }
89    let mut visited: HashSet<RuleId> = HashSet::new();
90    let mut on_stack: HashSet<RuleId> = HashSet::new();
91    for r in rules {
92        if !visited.contains(&r.id) && dfs_has_cycle(r.id, &deps, &mut visited, &mut on_stack) {
93            return true;
94        }
95    }
96    false
97}
98
99fn dfs_has_cycle(
100    node: RuleId,
101    deps: &HashMap<RuleId, Vec<RuleId>>,
102    visited: &mut HashSet<RuleId>,
103    on_stack: &mut HashSet<RuleId>,
104) -> bool {
105    visited.insert(node);
106    on_stack.insert(node);
107    if let Some(neighbors) = deps.get(&node) {
108        for &next in neighbors {
109            if !visited.contains(&next) {
110                if dfs_has_cycle(next, deps, visited, on_stack) {
111                    return true;
112                }
113            } else if on_stack.contains(&next) {
114                return true;
115            }
116        }
117    }
118    on_stack.remove(&node);
119    false
120}
121
122/// Build all possible arguments from a knowledge base and rule set by
123/// forward chaining to a fixed point.
124///
125/// Returns [`crate::Error::Aspic`] if the rule set contains a dependency
126/// cycle (which would prevent termination).
127pub fn construct_arguments(
128    kb: &KnowledgeBase,
129    rules: &[Rule],
130) -> Result<Vec<Argument>, crate::Error> {
131    if rule_set_has_cycle(rules) {
132        return Err(crate::Error::Aspic(
133            "cyclic rule dependencies detected; forward chaining would not terminate".into(),
134        ));
135    }
136    let mut arguments: Vec<Argument> = Vec::new();
137    let mut next_id = 0usize;
138    // Step 1: every premise is a leaf argument.
139    for p in kb.premises() {
140        arguments.push(Argument {
141            id: ArgumentId(next_id),
142            conclusion: p.literal().clone(),
143            origin: Origin::Premise(p.clone()),
144            sub_arguments: Vec::new(),
145        });
146        next_id += 1;
147    }
148    // Step 2: iterate forward chaining until no new arguments are added.
149    loop {
150        let before = arguments.len();
151        // Index arguments by their conclusion for quick lookup.
152        let mut by_concl: HashMap<Literal, Vec<ArgumentId>> = HashMap::new();
153        for a in &arguments {
154            by_concl.entry(a.conclusion.clone()).or_default().push(a.id);
155        }
156        // For each rule, try every combination of sub-arguments matching premises.
157        for rule in rules {
158            let sub_options: Vec<&[ArgumentId]> = rule
159                .premises
160                .iter()
161                .map(|p| by_concl.get(p).map(|v| v.as_slice()).unwrap_or(&[]))
162                .collect();
163            if sub_options.iter().any(|v| v.is_empty()) {
164                continue;
165            }
166            // Cartesian product across premise positions.
167            let combos = cartesian(&sub_options);
168            for combo in combos {
169                let already_exists = arguments.iter().any(|existing| {
170                    matches!(&existing.origin, Origin::RuleApplication(rid) if *rid == rule.id)
171                        && existing.sub_arguments == combo
172                });
173                if already_exists {
174                    continue;
175                }
176                arguments.push(Argument {
177                    id: ArgumentId(next_id),
178                    conclusion: rule.conclusion.clone(),
179                    origin: Origin::RuleApplication(rule.id),
180                    sub_arguments: combo,
181                });
182                next_id += 1;
183            }
184        }
185        if arguments.len() == before {
186            return Ok(arguments);
187        }
188    }
189}
190
191fn cartesian(options: &[&[ArgumentId]]) -> Vec<Vec<ArgumentId>> {
192    let mut result = vec![Vec::new()];
193    for opt in options {
194        let mut next = Vec::new();
195        for prefix in &result {
196            for &id in *opt {
197                let mut new_prefix = prefix.clone();
198                new_prefix.push(id);
199                next.push(new_prefix);
200            }
201        }
202        result = next;
203    }
204    result
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::aspic::rules::{Rule, RuleId};
211
212    #[test]
213    fn single_premise_yields_leaf_argument() {
214        let mut kb = KnowledgeBase::new();
215        kb.add_ordinary(Literal::atom("p"));
216        let args = construct_arguments(&kb, &[]).unwrap();
217        assert_eq!(args.len(), 1);
218        assert!(args[0].is_premise());
219        assert_eq!(args[0].conclusion, Literal::atom("p"));
220    }
221
222    #[test]
223    fn rule_application_creates_compound_argument() {
224        let mut kb = KnowledgeBase::new();
225        kb.add_ordinary(Literal::atom("p"));
226        let rules = vec![Rule::defeasible(
227            RuleId(0),
228            vec![Literal::atom("p")],
229            Literal::atom("q"),
230        )];
231        let args = construct_arguments(&kb, &rules).unwrap();
232        assert_eq!(args.len(), 2);
233        let q_arg = args
234            .iter()
235            .find(|a| a.conclusion == Literal::atom("q"))
236            .unwrap();
237        assert!(matches!(q_arg.origin, Origin::RuleApplication(RuleId(0))));
238        assert_eq!(q_arg.sub_arguments.len(), 1);
239    }
240
241    #[test]
242    fn missing_premise_blocks_rule() {
243        let mut kb = KnowledgeBase::new();
244        kb.add_ordinary(Literal::atom("p"));
245        let rules = vec![Rule::defeasible(
246            RuleId(0),
247            vec![Literal::atom("r")],
248            Literal::atom("q"),
249        )];
250        let args = construct_arguments(&kb, &rules).unwrap();
251        assert_eq!(args.len(), 1);
252        assert_eq!(args[0].conclusion, Literal::atom("p"));
253    }
254
255    #[test]
256    fn cyclic_rules_are_rejected() {
257        let mut kb = KnowledgeBase::new();
258        kb.add_ordinary(Literal::atom("p"));
259        let rules = vec![Rule::defeasible(
260            RuleId(0),
261            vec![Literal::atom("p")],
262            Literal::atom("p"),
263        )];
264        let result = construct_arguments(&kb, &rules);
265        assert!(matches!(result, Err(crate::Error::Aspic(_))));
266    }
267
268    #[test]
269    fn indirect_cyclic_rules_are_rejected() {
270        let mut kb = KnowledgeBase::new();
271        kb.add_ordinary(Literal::atom("p"));
272        let rules = vec![
273            Rule::defeasible(RuleId(0), vec![Literal::atom("p")], Literal::atom("q")),
274            Rule::defeasible(RuleId(1), vec![Literal::atom("q")], Literal::atom("p")),
275        ];
276        let result = construct_arguments(&kb, &rules);
277        assert!(matches!(result, Err(crate::Error::Aspic(_))));
278    }
279
280    #[test]
281    fn argument_id_displays_as_a_prefix() {
282        assert_eq!(format!("{}", ArgumentId(0)), "A0");
283        assert_eq!(format!("{}", ArgumentId(42)), "A42");
284    }
285}