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}