Skip to main content

argumentation_weighted_bipolar/
reduce.rs

1//! Subset enumeration over (attacks ∪ supports) for weighted bipolar
2//! frameworks under Amgoud 2008 + Dunne 2011 semantics.
3
4use crate::error::Error;
5use crate::framework::WeightedBipolarFramework;
6use crate::types::Budget;
7use argumentation_bipolar::BipolarFramework;
8use std::fmt::Debug;
9use std::hash::Hash;
10
11/// Test `cost ≤ budget` with a relative epsilon that matches
12/// `sweep.rs` breakpoint dedup, so that e.g.
13/// `Budget::new(0.3)` tolerates a subset of weights 0.1 + 0.2
14/// (which sums to 0.30000000000000004 in f64).
15///
16/// Mirrors the relative-vs-absolute split used by `sweep.rs` `dedup_by`:
17/// relative epsilon when values are non-negligible, absolute floor only
18/// for both-near-zero values. This prevents e.g. `1e-13` from being
19/// mistakenly treated as "within" a zero budget.
20fn cost_within_budget(cost: f64, budget: f64) -> bool {
21    if cost <= budget {
22        return true;
23    }
24    let scale = cost.abs().max(budget.abs());
25    if scale > 1e-100 {
26        cost - budget <= 1e-9 * scale
27    } else {
28        cost - budget <= 1e-100
29    }
30}
31
32/// Upper bound on the combined attack + support edge count for exact
33/// subset enumeration. `2^24 ≈ 16.8M` subsets per residual build;
34/// larger frameworks return [`Error::TooManyEdges`].
35pub const EDGE_ENUMERATION_LIMIT: usize = 24;
36
37/// Enumerate the residual [`BipolarFramework`]s obtained by dropping
38/// every β-inconsistent subset `S` of `framework`'s edges. Returns one
39/// residual per subset; residuals are yielded in bit-mask order where
40/// bits `0..attacks.len()` index attacks and bits
41/// `attacks.len()..attacks.len() + supports.len()` index supports.
42pub fn wbipolar_residuals<A>(
43    framework: &WeightedBipolarFramework<A>,
44    budget: Budget,
45) -> Result<Vec<BipolarFramework<A>>, Error>
46where
47    A: Clone + Eq + Hash + Debug,
48{
49    let attacks: Vec<_> = framework.attacks().collect();
50    let supports: Vec<_> = framework.supports().collect();
51    let m_a = attacks.len();
52    let m_s = supports.len();
53    let m = m_a + m_s;
54
55    if m > EDGE_ENUMERATION_LIMIT {
56        return Err(Error::TooManyEdges {
57            edges: m,
58            limit: EDGE_ENUMERATION_LIMIT,
59        });
60    }
61
62    let args: Vec<A> = framework.arguments().cloned().collect();
63    let total = 1u64 << m;
64    let mut residuals = Vec::new();
65
66    for bits in 0..total {
67        let mut cost = 0.0_f64;
68        for (i, atk) in attacks.iter().enumerate() {
69            if bits & (1u64 << i) != 0 {
70                cost += atk.weight.value();
71            }
72        }
73        for (j, sup) in supports.iter().enumerate() {
74            if bits & (1u64 << (m_a + j)) != 0 {
75                cost += sup.weight.value();
76            }
77        }
78        if !cost_within_budget(cost, budget.value()) {
79            continue;
80        }
81
82        let mut bf: BipolarFramework<A> = BipolarFramework::new();
83        for a in &args {
84            bf.add_argument(a.clone());
85        }
86        for (i, atk) in attacks.iter().enumerate() {
87            if bits & (1u64 << i) == 0 {
88                bf.add_attack(atk.attacker.clone(), atk.target.clone());
89            }
90        }
91        for (j, sup) in supports.iter().enumerate() {
92            if bits & (1u64 << (m_a + j)) == 0 {
93                bf.add_support(sup.supporter.clone(), sup.supported.clone())?;
94            }
95        }
96        residuals.push(bf);
97    }
98
99    Ok(residuals)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn zero_budget_yields_single_residual_with_all_edges() {
108        let mut wbf = WeightedBipolarFramework::new();
109        wbf.add_weighted_attack("a", "b", 0.3).unwrap();
110        wbf.add_weighted_support("c", "a", 0.2).unwrap();
111        let residuals = wbipolar_residuals(&wbf, Budget::zero()).unwrap();
112        assert_eq!(residuals.len(), 1);
113        let r = &residuals[0];
114        assert_eq!(r.attacks().count(), 1);
115        assert_eq!(r.supports().count(), 1);
116    }
117
118    #[test]
119    fn large_budget_yields_full_power_set_over_edges() {
120        let mut wbf = WeightedBipolarFramework::new();
121        wbf.add_weighted_attack("a", "b", 0.5).unwrap();
122        wbf.add_weighted_support("c", "a", 0.5).unwrap();
123        let residuals = wbipolar_residuals(&wbf, Budget::new(10.0).unwrap()).unwrap();
124        assert_eq!(residuals.len(), 4);
125    }
126
127    #[test]
128    fn budget_at_cheapest_edge_yields_two_residuals() {
129        let mut wbf = WeightedBipolarFramework::new();
130        wbf.add_weighted_attack("a", "b", 0.2).unwrap();
131        wbf.add_weighted_support("c", "a", 0.9).unwrap();
132        let residuals = wbipolar_residuals(&wbf, Budget::new(0.2).unwrap()).unwrap();
133        assert_eq!(residuals.len(), 2);
134    }
135
136    #[test]
137    fn oversized_framework_rejected() {
138        let mut wbf: WeightedBipolarFramework<u32> = WeightedBipolarFramework::new();
139        for i in 0..(EDGE_ENUMERATION_LIMIT as u32 + 1) {
140            wbf.add_weighted_attack(2 * i, 2 * i + 1, 0.1).unwrap();
141        }
142        let err = wbipolar_residuals(&wbf, Budget::new(1.0).unwrap()).unwrap_err();
143        assert!(matches!(err, Error::TooManyEdges { .. }));
144    }
145
146    #[test]
147    fn every_residual_preserves_all_arguments() {
148        let mut wbf = WeightedBipolarFramework::new();
149        wbf.add_argument("isolated");
150        wbf.add_weighted_attack("a", "b", 0.5).unwrap();
151        let residuals = wbipolar_residuals(&wbf, Budget::new(1.0).unwrap()).unwrap();
152        for r in &residuals {
153            assert_eq!(r.arguments().count(), 3);
154        }
155    }
156
157    #[test]
158    fn budget_tolerates_floating_point_sum_of_intended_weights() {
159        // Mirror of argumentation-weighted test: weights 0.1 + 0.2
160        // must be jointly tolerated by Budget::new(0.3) despite f64
161        // rounding summing them to 0.30000000000000004.
162        let mut wbf = WeightedBipolarFramework::new();
163        wbf.add_weighted_attack("a", "b", 0.1).unwrap();
164        wbf.add_weighted_support("c", "a", 0.2).unwrap();
165        let residuals = wbipolar_residuals(&wbf, Budget::new(0.3).unwrap()).unwrap();
166        // The "drop both edges" residual has 0 attacks + 0 supports.
167        let dropped_both = residuals
168            .iter()
169            .any(|bf| bf.attacks().count() == 0 && bf.supports().count() == 0);
170        assert!(
171            dropped_both,
172            "expected to find residual with both edges dropped at budget 0.3"
173        );
174    }
175}