Skip to main content

argumentation_weighted/
reduce.rs

1//! Dunne 2011 β-inconsistent residual enumeration.
2//!
3//! Given a weighted framework `WF` and budget `β`, [`dunne_residuals`]
4//! returns the plain Dung framework obtained by dropping attacks in
5//! each subset `S ⊆ attacks(WF)` whose cumulative weight is at most
6//! `β`. The acceptance predicates in [`crate::semantics`] iterate these
7//! residuals to compute β-credulous and β-skeptical acceptance.
8//!
9//! ## Complexity
10//!
11//! Enumeration is O(2^m · f(n)) where `m = |attacks(WF)|`, `n =
12//! |arguments(WF)|`, and `f(n)` is the Dung semantics cost on the
13//! residual. [`ATTACK_ENUMERATION_LIMIT`] caps `m` at 24 to keep the
14//! factor manageable; larger frameworks return
15//! [`crate::Error::TooManyAttacks`].
16//!
17//! ## v0.1.0 → v0.2.0 migration note
18//!
19//! v0.1.0 exposed `reduce_at_budget(wf, β) -> ArgumentationFramework`,
20//! a cumulative-threshold *approximation* that returned a single
21//! residual. That function is removed in v0.2.0: there is no canonical
22//! "the" residual under Dunne 2011, so the semantics layer iterates
23//! all residuals internally and callers should use
24//! [`crate::semantics`] acceptance predicates instead.
25
26use crate::error::Error;
27use crate::framework::WeightedFramework;
28use crate::types::Budget;
29use argumentation::ArgumentationFramework;
30use std::fmt::Debug;
31use std::hash::Hash;
32
33/// Test `cost ≤ budget` with a relative epsilon that matches
34/// `sweep.rs` breakpoint dedup, so that e.g.
35/// `Budget::new(0.3)` tolerates a subset of weights 0.1 + 0.2
36/// (which sums to 0.30000000000000004 in f64).
37///
38/// Mirrors the relative-vs-absolute split used by `sweep.rs` `dedup_by`:
39/// relative epsilon when values are non-negligible, absolute floor only
40/// for both-near-zero values. This prevents e.g. `1e-13` from being
41/// mistakenly treated as "within" a zero budget.
42fn cost_within_budget(cost: f64, budget: f64) -> bool {
43    if cost <= budget {
44        return true;
45    }
46    let scale = cost.abs().max(budget.abs());
47    if scale > 1e-100 {
48        cost - budget <= 1e-9 * scale
49    } else {
50        cost - budget <= 1e-100
51    }
52}
53
54/// Upper bound on attack count for exact Dunne 2011 subset enumeration.
55///
56/// At `n = 24` the power-set iteration visits `~16.8M` subsets; in
57/// release builds with the straight-line Dung enumerator on the
58/// residual this stays under ~2 seconds on commodity hardware. Larger
59/// frameworks hit [`crate::Error::TooManyAttacks`].
60///
61/// The core crate enforces a separate limit on arguments for its own
62/// subset enumerators (22, see `argumentation::semantics::subset_enum`);
63/// the two limits are independent because they count different things.
64pub const ATTACK_ENUMERATION_LIMIT: usize = 24;
65
66/// Enumerate the Dung residuals of `framework` at budget `β`.
67///
68/// A residual is `WF \ S` for some β-inconsistent `S` — i.e., the
69/// plain Dung framework with the attacks in `S` omitted. Every argument
70/// is preserved in every residual; only attack edges differ.
71///
72/// Returns one [`ArgumentationFramework`] per β-inconsistent subset.
73/// With `m` attacks, the maximum residual count is `2^m`; the budget
74/// typically prunes this substantially. Residuals are returned in bit-
75/// mask order (subset 0 = no attacks dropped; subset `2^m - 1` = all
76/// attacks dropped).
77///
78/// Fails with [`Error::TooManyAttacks`] if the framework has more
79/// than [`ATTACK_ENUMERATION_LIMIT`] attacks.
80pub fn dunne_residuals<A>(
81    framework: &WeightedFramework<A>,
82    budget: Budget,
83) -> Result<Vec<ArgumentationFramework<A>>, Error>
84where
85    A: Clone + Eq + Hash + Debug,
86{
87    let attacks: Vec<&crate::types::WeightedAttack<A>> = framework.attacks().collect();
88    let m = attacks.len();
89
90    if m > ATTACK_ENUMERATION_LIMIT {
91        return Err(Error::TooManyAttacks {
92            attacks: m,
93            limit: ATTACK_ENUMERATION_LIMIT,
94        });
95    }
96
97    let args: Vec<A> = framework.arguments().cloned().collect();
98    let total = 1u64 << m;
99    let mut residuals = Vec::new();
100
101    for bits in 0..total {
102        // Compute the cumulative weight of the dropped set S (bits
103        // where the corresponding attack is tolerated, i.e., removed).
104        let mut cost = 0.0_f64;
105        for (i, atk) in attacks.iter().enumerate() {
106            if bits & (1u64 << i) != 0 {
107                cost += atk.weight.value();
108            }
109        }
110        if !cost_within_budget(cost, budget.value()) {
111            continue;
112        }
113
114        // Build the residual: all arguments, and all attacks NOT in S.
115        let mut af: ArgumentationFramework<A> = ArgumentationFramework::new();
116        for a in &args {
117            af.add_argument(a.clone());
118        }
119        for (i, atk) in attacks.iter().enumerate() {
120            if bits & (1u64 << i) == 0 {
121                af.add_attack(&atk.attacker, &atk.target)?;
122            }
123        }
124        residuals.push(af);
125    }
126
127    Ok(residuals)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn attack_enumeration_limit_is_24() {
136        assert_eq!(super::ATTACK_ENUMERATION_LIMIT, 24);
137    }
138
139    #[test]
140    fn dunne_residuals_zero_budget_yields_single_residual_with_all_attacks() {
141        let mut wf = WeightedFramework::new();
142        wf.add_weighted_attack("a", "b", 0.5).unwrap();
143        wf.add_weighted_attack("c", "d", 0.8).unwrap();
144        let residuals = dunne_residuals(&wf, Budget::zero()).unwrap();
145        assert_eq!(residuals.len(), 1);
146        assert_eq!(residuals[0].attackers(&"b").len(), 1);
147        assert_eq!(residuals[0].attackers(&"d").len(), 1);
148    }
149
150    #[test]
151    fn dunne_residuals_budget_covers_cheapest_attack_yields_two_residuals() {
152        let mut wf = WeightedFramework::new();
153        wf.add_weighted_attack("a", "b", 0.3).unwrap();
154        wf.add_weighted_attack("c", "d", 0.9).unwrap();
155        let residuals = dunne_residuals(&wf, Budget::new(0.3).unwrap()).unwrap();
156        assert_eq!(residuals.len(), 2);
157    }
158
159    #[test]
160    fn dunne_residuals_large_budget_yields_full_power_set() {
161        let mut wf = WeightedFramework::new();
162        wf.add_weighted_attack("a", "b", 0.5).unwrap();
163        wf.add_weighted_attack("c", "d", 0.5).unwrap();
164        let residuals = dunne_residuals(&wf, Budget::new(10.0).unwrap()).unwrap();
165        assert_eq!(residuals.len(), 4);
166    }
167
168    #[test]
169    fn dunne_residuals_rejects_oversized_framework() {
170        let mut wf: WeightedFramework<u32> = WeightedFramework::new();
171        for i in 0..(ATTACK_ENUMERATION_LIMIT as u32 + 1) {
172            wf.add_weighted_attack(2 * i, 2 * i + 1, 0.1).unwrap();
173        }
174        let err = dunne_residuals(&wf, Budget::new(1.0).unwrap()).unwrap_err();
175        assert!(matches!(err, Error::TooManyAttacks { .. }));
176    }
177
178    #[test]
179    fn dunne_residuals_preserves_all_arguments_in_every_residual() {
180        let mut wf = WeightedFramework::new();
181        wf.add_argument("isolated");
182        wf.add_weighted_attack("a", "b", 0.5).unwrap();
183        let residuals = dunne_residuals(&wf, Budget::new(1.0).unwrap()).unwrap();
184        for r in &residuals {
185            assert_eq!(r.len(), 3);
186        }
187    }
188
189    #[test]
190    fn budget_tolerates_floating_point_sum_of_intended_weights() {
191        // Budget::new(0.3) should tolerate two attacks summing to 0.3
192        // in f64 (0.1 + 0.2 = 0.30000000000000004). Strict `>` would
193        // silently exclude this subset, giving only 3 residuals instead
194        // of the full power-set of 4.
195        let mut wf = WeightedFramework::new();
196        wf.add_weighted_attack("a", "b", 0.1).unwrap();
197        wf.add_weighted_attack("c", "d", 0.2).unwrap();
198        let residuals = dunne_residuals(&wf, Budget::new(0.3).unwrap()).unwrap();
199        // With epsilon tolerance all 4 subsets (including "drop both
200        // attacks") are within budget; strict comparison would yield 3.
201        assert_eq!(
202            residuals.len(),
203            4,
204            "expected full power set of 4 residuals at budget 0.3, got {}",
205            residuals.len()
206        );
207    }
208}