Skip to main content

argumentation_bipolar/
derived.rs

1//! Derived attack closure per Cayrol & Lagasquie-Schiex 2005 and
2//! Amgoud et al. 2008 §3.
3//!
4//! Given a [`BipolarFramework`], compute the set of all attacks (direct
5//! plus derived) that hold under necessary-support semantics. Three
6//! derivation rules:
7//!
8//! 1. **Direct**: every edge in the attack set is an attack.
9//! 2. **Supported**: if `A` transitively supports `X` and `X` directly
10//!    attacks `B`, then `A` attacks `B`.
11//! 3. **Secondary/Mediated**: if `A` directly attacks `X` and `X`
12//!    transitively supports `C`, then `A` attacks `C`. (Amgoud et al.
13//!    distinguishes secondary and mediated but both produce the same
14//!    edges under the necessary-support reading.)
15//!
16//! The closure is computed as a fixed point over all three rules
17//! applied together. For a framework with `n` arguments, convergence is
18//! bounded by `n` iterations and the closure has at most `n²` edges.
19
20use crate::framework::BipolarFramework;
21use std::collections::HashSet;
22use std::hash::Hash;
23
24/// Compute the closure of support from each argument: for each `A`,
25/// the set of arguments `X` such that `A` transitively supports `X`.
26/// Uses repeated BFS over the direct support edges.
27fn support_closure<A: Clone + Eq + Hash>(
28    framework: &BipolarFramework<A>,
29) -> std::collections::HashMap<A, HashSet<A>> {
30    use std::collections::{HashMap, VecDeque};
31
32    let mut closure: HashMap<A, HashSet<A>> = HashMap::new();
33    for arg in framework.arguments() {
34        closure.insert(arg.clone(), HashSet::new());
35    }
36
37    // For each argument `start`, BFS the support graph to find every
38    // transitively supported argument.
39    for start in framework.arguments() {
40        let mut visited: HashSet<A> = HashSet::new();
41        let mut frontier: VecDeque<A> = VecDeque::new();
42        frontier.push_back(start.clone());
43        while let Some(current) = frontier.pop_front() {
44            for (sup, supd) in framework.supports() {
45                if *sup == current && visited.insert(supd.clone()) {
46                    frontier.push_back(supd.clone());
47                }
48            }
49        }
50        closure.insert(start.clone(), visited);
51    }
52
53    closure
54}
55
56/// Compute the closed attack set for a bipolar framework under
57/// necessary-support semantics.
58///
59/// The returned set contains `(attacker, target)` pairs for every
60/// direct attack plus every derived attack produced by the supported
61/// and secondary/mediated rules. Self-attacks are preserved from the
62/// direct set (Dung allows them) but are not introduced by derivation.
63///
64/// The closure is deterministic and order-independent.
65pub fn closed_attacks<A>(framework: &BipolarFramework<A>) -> HashSet<(A, A)>
66where
67    A: Clone + Eq + Hash,
68{
69    let support_cl = support_closure(framework);
70
71    let mut closed: HashSet<(A, A)> = HashSet::new();
72
73    // Rule 1: direct attacks.
74    for (a, b) in framework.attacks() {
75        closed.insert((a.clone(), b.clone()));
76    }
77
78    // Rule 2: supported attack — A supports* X, X attacks B ⇒ A attacks B.
79    // For every direct attack (X, B) and every A with X ∈ support_cl(A),
80    // insert (A, B).
81    for (x, b) in framework.attacks() {
82        for (a, supported_by_a) in &support_cl {
83            if supported_by_a.contains(x) {
84                closed.insert((a.clone(), b.clone()));
85            }
86        }
87    }
88
89    // Rule 3: secondary / mediated attack — A attacks X, X supports* C ⇒ A attacks C.
90    // For every direct attack (A, X) and every C in support_cl(X), insert (A, C).
91    for (a, x) in framework.attacks() {
92        if let Some(downstream) = support_cl.get(x) {
93            for c in downstream {
94                closed.insert((a.clone(), c.clone()));
95            }
96        }
97    }
98
99    // Compose: A supports* X, X attacks Y, Y supports* C ⇒ A attacks C.
100    // This is the full two-sided closure. The simpler implementation
101    // is to iterate the above two rules to a fixed point; but because
102    // support is transitively closed in `support_cl`, the single-pass
103    // combination captures both directions without iteration:
104    for (x, y) in framework.attacks() {
105        for (a, supported_by_a) in &support_cl {
106            if !supported_by_a.contains(x) {
107                continue;
108            }
109            if let Some(downstream_of_y) = support_cl.get(y) {
110                for c in downstream_of_y {
111                    closed.insert((a.clone(), c.clone()));
112                }
113            }
114        }
115    }
116
117    closed
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn direct_attack_is_preserved() {
126        let mut bf = BipolarFramework::new();
127        bf.add_attack("a", "b");
128        let closed = closed_attacks(&bf);
129        assert!(closed.contains(&("a", "b")));
130        assert_eq!(closed.len(), 1);
131    }
132
133    #[test]
134    fn supported_attack_rule_fires() {
135        // a supports x, x attacks b ⇒ a attacks b (derived).
136        let mut bf = BipolarFramework::new();
137        bf.add_support("a", "x").unwrap();
138        bf.add_attack("x", "b");
139        let closed = closed_attacks(&bf);
140        assert!(closed.contains(&("x", "b"))); // direct
141        assert!(closed.contains(&("a", "b"))); // supported
142    }
143
144    #[test]
145    fn secondary_attack_rule_fires() {
146        // a attacks x, x supports c ⇒ a attacks c (secondary).
147        let mut bf = BipolarFramework::new();
148        bf.add_attack("a", "x");
149        bf.add_support("x", "c").unwrap();
150        let closed = closed_attacks(&bf);
151        assert!(closed.contains(&("a", "x"))); // direct
152        assert!(closed.contains(&("a", "c"))); // secondary
153    }
154
155    #[test]
156    fn two_sided_closure_composes_supported_and_secondary() {
157        // a supports x, x attacks y, y supports c ⇒ a attacks c
158        // (full closure: supported + secondary in one pass).
159        let mut bf = BipolarFramework::new();
160        bf.add_support("a", "x").unwrap();
161        bf.add_attack("x", "y");
162        bf.add_support("y", "c").unwrap();
163        let closed = closed_attacks(&bf);
164        assert!(closed.contains(&("x", "y")));
165        assert!(closed.contains(&("a", "y"))); // supported half
166        assert!(closed.contains(&("x", "c"))); // secondary half
167        assert!(closed.contains(&("a", "c"))); // full two-sided closure
168    }
169
170    #[test]
171    fn transitive_support_chain_propagates_supported_attack() {
172        // a supports b, b supports x, x attacks target ⇒ a attacks target.
173        let mut bf = BipolarFramework::new();
174        bf.add_support("a", "b").unwrap();
175        bf.add_support("b", "x").unwrap();
176        bf.add_attack("x", "target");
177        let closed = closed_attacks(&bf);
178        assert!(closed.contains(&("x", "target")));
179        assert!(closed.contains(&("b", "target")));
180        assert!(closed.contains(&("a", "target")));
181    }
182
183    #[test]
184    fn isolated_arguments_produce_no_derived_attacks() {
185        let mut bf = BipolarFramework::new();
186        bf.add_argument("a");
187        bf.add_argument("b");
188        bf.add_argument("c");
189        assert!(closed_attacks(&bf).is_empty());
190    }
191}