Skip to main content

argumentation/aspic/
attacks.rs

1//! Attack relations between ASPIC+ arguments.
2//!
3//! Per Modgil & Prakken 2014 §3.3.1 Definition 3.10:
4//! - **Undermining:** `A` undermines `B` (on `ϕ`) iff `A`'s conclusion is the
5//!   contrary of some *ordinary premise* `ϕ` used anywhere in `B`'s tree.
6//! - **Undercutting:** `A` undercuts `B` (on `B'`) iff `A`'s conclusion
7//!   expresses "the defeasible rule `r` does not apply," for some defeasible
8//!   rule `r` used in a defeasible-topped sub-argument `B' ∈ Sub(B)`. Our
9//!   encoding uses the reserved literal namespace `¬__applicable_<rule_id>`;
10//!   consumers should use
11//!   [`crate::aspic::StructuredSystem::add_undercut_rule`] rather than
12//!   constructing this literal by hand.
13//! - **Rebutting:** `A` rebuts `B` (on `B'`) iff `A`'s conclusion is the
14//!   contrary of some sub-argument `B' ∈ Sub(B)`'s conclusion, and `B'`'s
15//!   top rule is defeasible.
16//!
17//! **All three attack kinds are recorded against the outer target `B`**, not
18//! just against the sub-argument where the conflict lands. This matches the
19//! paper's "A rebuts B on B'" phrasing: the Dung AF edge is `(A, B)`. Because
20//! `construct_arguments` also materialises every intermediate sub-argument as
21//! its own `Argument` with its own id, the (attacker, target) iteration
22//! additionally yields `(A, B')` as a separate edge — both edges exist in the
23//! resulting framework, which is consistent with the paper's treatment of
24//! every sub-argument as an independent argument.
25//!
26//! Historical note: an earlier version of this module only checked the
27//! target's top-level conclusion for rebut, which produced logically
28//! inconsistent extensions when strict rules wrapped defeasible
29//! sub-arguments (e.g. Example 4.1 Married/Bachelor from M&P 2014). The
30//! sub-argument traversal in [`compute_attacks`]'s rebut loop is the fix.
31
32use super::argument::{Argument, ArgumentId, Origin};
33use super::kb::Premise;
34use super::rules::Rule;
35
36/// The kind of attack.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum AttackKind {
39    /// Attacks an ordinary premise used by the target argument.
40    Undermine,
41    /// Attacks a defeasible rule used by the target argument.
42    Undercut,
43    /// Attacks the target's conclusion (and the target ends in a defeasible rule).
44    Rebut,
45}
46
47impl std::fmt::Display for AttackKind {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            AttackKind::Undermine => write!(f, "undermine"),
51            AttackKind::Undercut => write!(f, "undercut"),
52            AttackKind::Rebut => write!(f, "rebut"),
53        }
54    }
55}
56
57/// An attack between two ASPIC+ arguments.
58#[derive(Debug, Clone, PartialEq, Eq, Hash)]
59pub struct Attack {
60    /// The attacking argument.
61    pub attacker: ArgumentId,
62    /// The argument being attacked.
63    pub target: ArgumentId,
64    /// The kind of attack.
65    pub kind: AttackKind,
66}
67
68/// Recursively collect all defeasible rules used in an argument's construction.
69fn defeasible_rules_used(
70    arg: &Argument,
71    args: &[Argument],
72    rules: &[Rule],
73) -> Vec<super::rules::RuleId> {
74    let mut out = Vec::new();
75    match &arg.origin {
76        Origin::Premise(_) => {}
77        Origin::RuleApplication(rid) => {
78            if let Some(r) = rules.iter().find(|r| r.id == *rid)
79                && r.is_defeasible()
80            {
81                out.push(*rid);
82            }
83            for sub_id in &arg.sub_arguments {
84                if let Some(sub) = args.iter().find(|a| a.id == *sub_id) {
85                    out.extend(defeasible_rules_used(sub, args, rules));
86                }
87            }
88        }
89    }
90    out
91}
92
93/// Recursively collect all ordinary premises used in an argument.
94fn ordinary_premises_used<'a>(arg: &'a Argument, args: &'a [Argument]) -> Vec<&'a Premise> {
95    let mut out = Vec::new();
96    match &arg.origin {
97        Origin::Premise(p) => {
98            if p.is_defeasible() {
99                out.push(p);
100            }
101        }
102        Origin::RuleApplication(_) => {
103            for sub_id in &arg.sub_arguments {
104                if let Some(sub) = args.iter().find(|a| a.id == *sub_id) {
105                    out.extend(ordinary_premises_used(sub, args));
106                }
107            }
108        }
109    }
110    out
111}
112
113/// Recursively collect `arg` and all its sub-arguments (transitive closure).
114///
115/// Used by the rebut check to find defeasible-topped sub-arguments whose
116/// conclusion the attacker might contradict; per M&P 2014 §3.3.1, rebutting
117/// on a sub-argument is an attack on the outer parent.
118fn all_sub_arguments<'a>(arg: &'a Argument, args: &'a [Argument]) -> Vec<&'a Argument> {
119    let mut out = vec![arg];
120    for sub_id in &arg.sub_arguments {
121        if let Some(sub) = args.iter().find(|a| a.id == *sub_id) {
122            out.extend(all_sub_arguments(sub, args));
123        }
124    }
125    out
126}
127
128/// Compute all attacks between arguments.
129pub fn compute_attacks(args: &[Argument], rules: &[Rule]) -> Vec<Attack> {
130    // Precompute per-target helper results once. Each is indexed by
131    // ArgumentId so the inner loop is O(1) lookup.
132    let mut premises_by_target: std::collections::HashMap<ArgumentId, Vec<&Premise>> =
133        std::collections::HashMap::with_capacity(args.len());
134    let mut defeasible_rules_by_target: std::collections::HashMap<
135        ArgumentId,
136        Vec<super::rules::RuleId>,
137    > = std::collections::HashMap::with_capacity(args.len());
138    let mut subs_by_target: std::collections::HashMap<ArgumentId, Vec<&Argument>> =
139        std::collections::HashMap::with_capacity(args.len());
140    for target in args {
141        premises_by_target.insert(target.id, ordinary_premises_used(target, args));
142        defeasible_rules_by_target.insert(target.id, defeasible_rules_used(target, args, rules));
143        subs_by_target.insert(target.id, all_sub_arguments(target, args));
144    }
145
146    let mut attacks = Vec::new();
147    for attacker in args {
148        for target in args {
149            if attacker.id == target.id {
150                continue;
151            }
152            // Rebut: attacker's conclusion is contrary of some
153            // defeasible-topped sub-argument of target. Per M&P 2014 §3.3.1
154            // Def 3.10, the edge is (attacker, target) regardless of
155            // whether the rebut lands at the top or deeper in the tree.
156            let subs = subs_by_target.get(&target.id).expect("target precomputed");
157            if subs.iter().any(|sub| {
158                attacker.conclusion.is_contrary_of(&sub.conclusion)
159                    && sub.top_rule_is_defeasible(rules)
160            }) {
161                attacks.push(Attack {
162                    attacker: attacker.id,
163                    target: target.id,
164                    kind: AttackKind::Rebut,
165                });
166            }
167            // Undermine: attacker's conclusion is contrary of an ordinary
168            // premise used anywhere in target's sub-tree.
169            let target_premises = premises_by_target
170                .get(&target.id)
171                .expect("target precomputed");
172            if target_premises
173                .iter()
174                .any(|prem| attacker.conclusion.is_contrary_of(prem.literal()))
175            {
176                attacks.push(Attack {
177                    attacker: attacker.id,
178                    target: target.id,
179                    kind: AttackKind::Undermine,
180                });
181            }
182            // Undercut: attacker's conclusion is the reserved
183            // `¬__applicable_<rule_id>` marker for some defeasible rule used
184            // in target's sub-tree.
185            let target_rules = defeasible_rules_by_target
186                .get(&target.id)
187                .expect("target precomputed");
188            if target_rules
189                .iter()
190                .any(|rid| attacker.conclusion == super::language::Literal::undercut_marker(rid.0))
191            {
192                attacks.push(Attack {
193                    attacker: attacker.id,
194                    target: target.id,
195                    kind: AttackKind::Undercut,
196                });
197            }
198        }
199    }
200    attacks
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::aspic::argument::construct_arguments;
207    use crate::aspic::kb::KnowledgeBase;
208    use crate::aspic::language::Literal;
209    use crate::aspic::rules::{Rule, RuleId};
210
211    #[test]
212    fn rebut_detected_between_contrary_conclusions() {
213        let mut kb = KnowledgeBase::new();
214        kb.add_ordinary(Literal::atom("p"));
215        kb.add_ordinary(Literal::atom("r"));
216        let rules = vec![
217            Rule::defeasible(RuleId(0), vec![Literal::atom("p")], Literal::atom("q")),
218            Rule::defeasible(RuleId(1), vec![Literal::atom("r")], Literal::neg("q")),
219        ];
220        let args = construct_arguments(&kb, &rules).unwrap();
221        let attacks = compute_attacks(&args, &rules);
222        let rebuts: Vec<&Attack> = attacks
223            .iter()
224            .filter(|a| a.kind == AttackKind::Rebut)
225            .collect();
226        assert!(rebuts.len() >= 2);
227    }
228
229    #[test]
230    fn undermine_detected_on_ordinary_premise() {
231        let mut kb = KnowledgeBase::new();
232        kb.add_ordinary(Literal::atom("p"));
233        kb.add_ordinary(Literal::atom("r"));
234        let rules = vec![Rule::defeasible(
235            RuleId(0),
236            vec![Literal::atom("r")],
237            Literal::neg("p"),
238        )];
239        let args = construct_arguments(&kb, &rules).unwrap();
240        let attacks = compute_attacks(&args, &rules);
241        let undermines: Vec<&Attack> = attacks
242            .iter()
243            .filter(|a| a.kind == AttackKind::Undermine)
244            .collect();
245        assert!(!undermines.is_empty());
246    }
247
248    #[test]
249    fn undercut_detected_via_reserved_marker() {
250        // Target: defeasible rule r0: p => q. Arg `q` uses r0.
251        // Attacker: defeasible rule concluding ¬__applicable_0 (the reserved
252        // undercut marker for rule id 0). This is what `add_undercut_rule`
253        // produces internally, but we construct it here by hand so we're
254        // testing the attack-detection path independently.
255        let mut kb = KnowledgeBase::new();
256        kb.add_ordinary(Literal::atom("p"));
257        kb.add_ordinary(Literal::atom("trigger"));
258        let rules = vec![
259            Rule::defeasible(RuleId(0), vec![Literal::atom("p")], Literal::atom("q")),
260            Rule::defeasible(
261                RuleId(1),
262                vec![Literal::atom("trigger")],
263                Literal::undercut_marker(0),
264            ),
265        ];
266        let args = construct_arguments(&kb, &rules).unwrap();
267        let attacks = compute_attacks(&args, &rules);
268        let undercuts: Vec<&Attack> = attacks
269            .iter()
270            .filter(|a| a.kind == AttackKind::Undercut)
271            .collect();
272        assert!(
273            !undercuts.is_empty(),
274            "expected at least one Undercut attack, got {:?}",
275            attacks
276        );
277        // The undercut must target the q-argument (built from r0).
278        let q_arg = args
279            .iter()
280            .find(|a| a.conclusion == Literal::atom("q"))
281            .unwrap();
282        assert!(
283            undercuts.iter().any(|u| u.target == q_arg.id),
284            "expected an undercut attack targeting the q-argument"
285        );
286    }
287
288    #[test]
289    fn rebut_propagates_through_strict_wrapper() {
290        // Regression test for M&P 2014 Example 4.1 (Married/Bachelor).
291        //
292        // KB: WearsRing, PartyAnimal (both ordinary)
293        // Rd: d1: WearsRing ⇒ Married
294        //     d2: PartyAnimal ⇒ Bachelor
295        // Rs: s1: Married → ¬Bachelor
296        //     s2: Bachelor → ¬Married
297        //
298        // Arguments:
299        //   A1 = WearsRing (premise)
300        //   A2 = A1 ⇒ Married (via d1, defeasible top)
301        //   A3 = A2 → ¬Bachelor (via s1, strict top)
302        //   B1 = PartyAnimal (premise)
303        //   B2 = B1 ⇒ Bachelor (via d2, defeasible top)
304        //   B3 = B2 → ¬Married (via s2, strict top)
305        //
306        // The paper says "A3 rebuts B3 on its subargument B2, and B3 rebuts
307        // A3 on its subargument A2." So in the Dung AF we must have BOTH:
308        //   - (A3, B2) and (A3, B3) for the A3-rebuts-B3-on-B2 claim
309        //   - (B3, A2) and (B3, A3) for the B3-rebuts-A3-on-A2 claim
310        //
311        // A previous implementation only detected the sub-argument landing
312        // (A3, B2) and (B3, A2), which left A3 and B3 unattacked in the AF —
313        // producing a grounded extension that contained both ¬Bachelor AND
314        // ¬Married while excluding both Married and Bachelor. Incoherent.
315        let mut kb = KnowledgeBase::new();
316        kb.add_ordinary(Literal::atom("WearsRing"));
317        kb.add_ordinary(Literal::atom("PartyAnimal"));
318        let rules = vec![
319            Rule::defeasible(
320                RuleId(0),
321                vec![Literal::atom("WearsRing")],
322                Literal::atom("Married"),
323            ),
324            Rule::defeasible(
325                RuleId(1),
326                vec![Literal::atom("PartyAnimal")],
327                Literal::atom("Bachelor"),
328            ),
329            Rule::strict(
330                RuleId(2),
331                vec![Literal::atom("Married")],
332                Literal::neg("Bachelor"),
333            ),
334            Rule::strict(
335                RuleId(3),
336                vec![Literal::atom("Bachelor")],
337                Literal::neg("Married"),
338            ),
339        ];
340        let args = construct_arguments(&kb, &rules).unwrap();
341        let all_attacks = compute_attacks(&args, &rules);
342        let rebuts: Vec<&Attack> = all_attacks
343            .iter()
344            .filter(|a| a.kind == AttackKind::Rebut)
345            .collect();
346        // Locate arguments by conclusion.
347        let married = args
348            .iter()
349            .find(|a| a.conclusion == Literal::atom("Married"))
350            .unwrap();
351        let bachelor = args
352            .iter()
353            .find(|a| a.conclusion == Literal::atom("Bachelor"))
354            .unwrap();
355        let not_bachelor = args
356            .iter()
357            .find(|a| a.conclusion == Literal::neg("Bachelor"))
358            .unwrap();
359        let not_married = args
360            .iter()
361            .find(|a| a.conclusion == Literal::neg("Married"))
362            .unwrap();
363
364        // Direct sub-arg rebuts (pre-existing behaviour):
365        assert!(
366            rebuts
367                .iter()
368                .any(|r| r.attacker == not_bachelor.id && r.target == bachelor.id),
369            "expected ¬Bachelor rebuts Bachelor (direct sub)"
370        );
371        assert!(
372            rebuts
373                .iter()
374                .any(|r| r.attacker == not_married.id && r.target == married.id),
375            "expected ¬Married rebuts Married (direct sub)"
376        );
377
378        // Strict-wrapper propagation (the fix):
379        assert!(
380            rebuts
381                .iter()
382                .any(|r| r.attacker == not_bachelor.id && r.target == not_married.id),
383            "expected ¬Bachelor rebuts ¬Married via sub-argument B2 (strict wrapper)"
384        );
385        assert!(
386            rebuts
387                .iter()
388                .any(|r| r.attacker == not_married.id && r.target == not_bachelor.id),
389            "expected ¬Married rebuts ¬Bachelor via sub-argument A2 (strict wrapper)"
390        );
391    }
392
393    #[test]
394    fn attack_kind_displays_as_lowercase_word() {
395        assert_eq!(format!("{}", AttackKind::Undermine), "undermine");
396        assert_eq!(format!("{}", AttackKind::Undercut), "undercut");
397        assert_eq!(format!("{}", AttackKind::Rebut), "rebut");
398    }
399
400    #[test]
401    fn compute_attacks_is_stable_across_refactors() {
402        let mut kb = KnowledgeBase::new();
403        kb.add_ordinary(Literal::atom("p"));
404        kb.add_ordinary(Literal::atom("r"));
405        kb.add_ordinary(Literal::atom("trigger"));
406        let rules = vec![
407            Rule::defeasible(RuleId(0), vec![Literal::atom("p")], Literal::atom("q")),
408            Rule::defeasible(RuleId(1), vec![Literal::atom("r")], Literal::neg("q")),
409            Rule::strict(
410                RuleId(2),
411                vec![Literal::atom("q")],
412                Literal::atom("derived"),
413            ),
414            Rule::defeasible(
415                RuleId(3),
416                vec![Literal::atom("trigger")],
417                Literal::undercut_marker(0),
418            ),
419        ];
420        let args = construct_arguments(&kb, &rules).unwrap();
421        let attacks = compute_attacks(&args, &rules);
422
423        let rebuts = attacks
424            .iter()
425            .filter(|a| a.kind == AttackKind::Rebut)
426            .count();
427        let undermines = attacks
428            .iter()
429            .filter(|a| a.kind == AttackKind::Undermine)
430            .count();
431        let undercuts = attacks
432            .iter()
433            .filter(|a| a.kind == AttackKind::Undercut)
434            .count();
435
436        assert!(rebuts >= 3, "expected >= 3 rebuts, got {}", rebuts);
437        assert_eq!(undermines, 0);
438        // Undercut hits both the defeasible-topped argument using rule 0
439        // directly (p ⇒ q) and its strict-wrapped parent (q → derived),
440        // because rule 0 lives in the latter's sub-argument tree too.
441        assert_eq!(undercuts, 2);
442    }
443
444    #[test]
445    fn rebut_propagates_through_three_level_strict_wrapper() {
446        // KB: p (ordinary), q (ordinary)
447        // Rules:
448        //   d1: p ⇒ x            (defeasible)
449        //   s1: x → y            (strict; wraps d1)
450        //   s2: y → z            (strict; wraps s1(d1))
451        //   d2: q ⇒ ¬x           (defeasible, rebuts d1's x-conclusion)
452        //
453        // Arguments:
454        //   A1: p          premise
455        //   A2: p ⇒ x      d1
456        //   A3: x → y      s1(A2)         [top strict, defeasible sub A2]
457        //   A4: y → z      s2(A3)         [top strict, defeasible sub two levels down]
458        //   B1: q          premise
459        //   B2: q ⇒ ¬x     d2             [top defeasible]
460        //
461        // Expected: B2 rebuts A2 directly (same level), and per the strict-wrap
462        // fix B2 must ALSO rebut A3 (via sub A2) AND A4 (via sub A2 two levels
463        // deep). If the recursion stops at depth 1, the A4 rebut is missed and
464        // A4 remains unattacked in the Dung AF — producing the same incoherent
465        // extensions as the original strict-wrap bug.
466        let mut kb = KnowledgeBase::new();
467        kb.add_ordinary(Literal::atom("p"));
468        kb.add_ordinary(Literal::atom("q"));
469        let rules = vec![
470            Rule::defeasible(RuleId(0), vec![Literal::atom("p")], Literal::atom("x")),
471            Rule::strict(RuleId(1), vec![Literal::atom("x")], Literal::atom("y")),
472            Rule::strict(RuleId(2), vec![Literal::atom("y")], Literal::atom("z")),
473            Rule::defeasible(RuleId(3), vec![Literal::atom("q")], Literal::neg("x")),
474        ];
475        let args = construct_arguments(&kb, &rules).unwrap();
476        let attacks = compute_attacks(&args, &rules);
477        let rebuts: Vec<&Attack> = attacks
478            .iter()
479            .filter(|a| a.kind == AttackKind::Rebut)
480            .collect();
481
482        let a2 = args
483            .iter()
484            .find(|a| a.conclusion == Literal::atom("x"))
485            .expect("x-argument");
486        let a3 = args
487            .iter()
488            .find(|a| a.conclusion == Literal::atom("y"))
489            .expect("y-argument");
490        let a4 = args
491            .iter()
492            .find(|a| a.conclusion == Literal::atom("z"))
493            .expect("z-argument");
494        let b2 = args
495            .iter()
496            .find(|a| a.conclusion == Literal::neg("x"))
497            .expect("¬x-argument");
498
499        assert!(
500            rebuts
501                .iter()
502                .any(|r| r.attacker == b2.id && r.target == a2.id),
503            "expected B2 rebuts A2 (direct)"
504        );
505        assert!(
506            rebuts
507                .iter()
508                .any(|r| r.attacker == b2.id && r.target == a3.id),
509            "expected B2 rebuts A3 (1-level strict wrap over A2)"
510        );
511        assert!(
512            rebuts
513                .iter()
514                .any(|r| r.attacker == b2.id && r.target == a4.id),
515            "expected B2 rebuts A4 (2-level strict wrap over A2)"
516        );
517    }
518}