Skip to main content

argumentation_weighted/
sweep.rs

1//! Threshold-sweep API: compute acceptance trajectories for one
2//! argument across the full budget range.
3//!
4//! Under Dunne 2011 semantics, acceptance can change at any distinct
5//! subset-sum of attack weights (up to `2^|attacks|` values). The
6//! sweep probes all such breakpoints to guarantee no flip is missed.
7//!
8//! ## Monotonicity
9//!
10//! Under Dunne 2011 semantics, credulous acceptance is monotone
11//! non-decreasing in β: if `x` is credulously accepted at some `β`, it
12//! is credulously accepted at every larger budget. [`min_budget_for_credulous`]
13//! is therefore well-defined and returns the infimum.
14
15use crate::error::Error;
16use crate::framework::WeightedFramework;
17use crate::semantics::{is_credulously_accepted_at, is_skeptically_accepted_at};
18use crate::types::Budget;
19use std::fmt::Debug;
20use std::hash::Hash;
21
22/// One point in a threshold sweep: the budget at which this point
23/// applies, and whether the target is accepted at that budget.
24#[derive(Debug, Clone, PartialEq)]
25pub struct SweepPoint {
26    /// The budget value at which this point was evaluated.
27    pub budget: f64,
28    /// Whether the target was accepted at that budget.
29    pub accepted: bool,
30}
31
32/// Which acceptance notion to use for the sweep.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum AcceptanceMode {
35    /// Credulous: in at least one preferred extension.
36    Credulous,
37    /// Skeptical: in every preferred extension.
38    Skeptical,
39}
40
41/// Compute the sorted list of budget breakpoints at which acceptance
42/// can change under Dunne 2011 semantics.
43///
44/// Under exact subset-enumeration semantics, acceptance can flip at
45/// any distinct subset sum of attack weights — up to `2^|attacks|`
46/// distinct values. The v0.1.0 approximation only probed cumulative
47/// sums (`m+1` values); that under-samples β and causes
48/// `min_budget_for_credulous` / `flip_points` to miss flip points that
49/// fall at non-cumulative subset sums.
50///
51/// If the framework has more than [`crate::reduce::ATTACK_ENUMERATION_LIMIT`]
52/// attacks the enumeration is impractical; in that case we fall back to
53/// `[0.0]` so callers get a `TooManyAttacks` error from the underlying
54/// semantics call rather than a silent wrong answer.
55fn breakpoints<A: Clone + Eq + Hash>(framework: &WeightedFramework<A>) -> Vec<f64> {
56    let weights: Vec<f64> = framework.attacks().map(|a| a.weight.value()).collect();
57    let m = weights.len();
58    if m > crate::reduce::ATTACK_ENUMERATION_LIMIT {
59        // Fallback: only probe β=0; caller will get TooManyAttacks from semantics fn.
60        return vec![0.0];
61    }
62    let total = 1u64 << m;
63    let mut sums: Vec<f64> = Vec::with_capacity(total as usize);
64    for bits in 0..total {
65        let mut s = 0.0_f64;
66        for (i, w) in weights.iter().enumerate() {
67            if bits & (1u64 << i) != 0 {
68                s += *w;
69            }
70        }
71        sums.push(s);
72    }
73    sums.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
74    sums.dedup_by(|a, b| {
75        let diff = (*a - *b).abs();
76        let scale = a.abs().max(b.abs());
77        // Use a relative epsilon when values are non-negligible; fall back
78        // to an absolute epsilon only for values that are both effectively
79        // zero. This prevents collapsing tiny-but-distinct sub-picosecond
80        // weights (e.g. 0.0 vs 1e-13) while still deduplicating
81        // floating-point rounding noise at larger magnitudes.
82        if scale > 1e-100 {
83            diff < 1e-9 * scale
84        } else {
85            diff < 1e-100
86        }
87    });
88    sums
89}
90
91/// Compute the full acceptance trajectory for `target` across the
92/// framework's budget range, returning one `SweepPoint` at every
93/// breakpoint.
94///
95/// The returned vector is sorted by `budget` ascending and starts at
96/// `budget = 0`. Use [`flip_points`] if you only want the budgets at
97/// which acceptance changes, not every breakpoint.
98pub fn acceptance_trajectory<A>(
99    framework: &WeightedFramework<A>,
100    target: &A,
101    mode: AcceptanceMode,
102) -> Result<Vec<SweepPoint>, Error>
103where
104    A: Clone + Eq + Hash + Debug + Ord,
105{
106    let mut out = Vec::new();
107    for bp in breakpoints(framework) {
108        let budget = Budget::new(bp)?;
109        let accepted = match mode {
110            AcceptanceMode::Credulous => is_credulously_accepted_at(framework, target, budget)?,
111            AcceptanceMode::Skeptical => is_skeptically_accepted_at(framework, target, budget)?,
112        };
113        out.push(SweepPoint {
114            budget: bp,
115            accepted,
116        });
117    }
118    Ok(out)
119}
120
121/// Return only the budgets at which `target`'s acceptance changes as
122/// β increases. Useful for the drama-manager flip-point query.
123pub fn flip_points<A>(
124    framework: &WeightedFramework<A>,
125    target: &A,
126    mode: AcceptanceMode,
127) -> Result<Vec<f64>, Error>
128where
129    A: Clone + Eq + Hash + Debug + Ord,
130{
131    let trajectory = acceptance_trajectory(framework, target, mode)?;
132    let mut flips = Vec::new();
133    let mut last_accepted: Option<bool> = None;
134    for point in trajectory {
135        if last_accepted != Some(point.accepted) {
136            if last_accepted.is_some() {
137                flips.push(point.budget);
138            }
139            last_accepted = Some(point.accepted);
140        }
141    }
142    Ok(flips)
143}
144
145/// Return the smallest budget at which `target` is credulously
146/// accepted, or `None` if it is never accepted across the framework's
147/// full budget range.
148///
149/// Under Dunne 2011 semantics, credulous acceptance is monotone in β,
150/// so the returned value is a stable threshold: once the target is
151/// accepted, it remains accepted for all larger budgets.
152pub fn min_budget_for_credulous<A>(
153    framework: &WeightedFramework<A>,
154    target: &A,
155) -> Result<Option<f64>, Error>
156where
157    A: Clone + Eq + Hash + Debug + Ord,
158{
159    let trajectory = acceptance_trajectory(framework, target, AcceptanceMode::Credulous)?;
160    Ok(trajectory
161        .into_iter()
162        .find(|p| p.accepted)
163        .map(|p| p.budget))
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn breakpoints_enumerates_all_distinct_subset_sums() {
172        // Three attacks with weights 0.2, 0.3, 0.5.
173        // All subset sums: {0.0, 0.2, 0.3, 0.5, 0.5, 0.7, 0.8, 1.0}
174        // After dedup (0.5 appears twice): [0.0, 0.2, 0.3, 0.5, 0.7, 0.8, 1.0] — 7 values.
175        // Old cumulative approach only produced [0.0, 0.2, 0.5, 1.0] — 4 values.
176        let mut wf = WeightedFramework::new();
177        wf.add_weighted_attack("a", "b", 0.2).unwrap();
178        wf.add_weighted_attack("c", "d", 0.3).unwrap();
179        wf.add_weighted_attack("e", "f", 0.5).unwrap();
180        let bps = breakpoints(&wf);
181        // Must include the non-cumulative subset sums 0.3, 0.7, 0.8 that
182        // the v0.1.0 cumulative approach missed.
183        assert_eq!(bps.len(), 7);
184        assert!((bps[0] - 0.0).abs() < 1e-9);
185        assert!((bps[1] - 0.2).abs() < 1e-9);
186        assert!((bps[2] - 0.3).abs() < 1e-9);
187        assert!((bps[3] - 0.5).abs() < 1e-9);
188        assert!((bps[4] - 0.7).abs() < 1e-9);
189        assert!((bps[5] - 0.8).abs() < 1e-9);
190        assert!((bps[6] - 1.0).abs() < 1e-9);
191    }
192
193    #[test]
194    fn unattacked_argument_is_accepted_at_every_budget() {
195        let mut wf = WeightedFramework::new();
196        wf.add_argument("unattacked");
197        wf.add_weighted_attack("a", "b", 0.5).unwrap();
198        let trajectory =
199            acceptance_trajectory(&wf, &"unattacked", AcceptanceMode::Credulous).unwrap();
200        assert!(trajectory.iter().all(|p| p.accepted));
201    }
202
203    #[test]
204    fn singly_attacked_argument_flips_at_attack_weight() {
205        let mut wf = WeightedFramework::new();
206        wf.add_weighted_attack("attacker", "target", 0.5).unwrap();
207        // At β=0: attacker defeats target (not accepted).
208        // At β=0.5: attack tolerated, target accepted.
209        let flips = flip_points(&wf, &"target", AcceptanceMode::Credulous).unwrap();
210        assert_eq!(flips.len(), 1);
211        assert!((flips[0] - 0.5).abs() < 1e-9);
212    }
213
214    #[test]
215    fn min_budget_for_credulous_finds_smallest_accepting_budget() {
216        let mut wf = WeightedFramework::new();
217        wf.add_weighted_attack("a", "target", 0.3).unwrap();
218        wf.add_weighted_attack("b", "target", 0.7).unwrap();
219        // Target accepted only once both attacks are tolerated (β ≥ 1.0).
220        let min = min_budget_for_credulous(&wf, &"target").unwrap();
221        assert_eq!(min, Some(1.0));
222    }
223
224    #[test]
225    fn min_budget_returns_none_for_self_attack() {
226        let mut wf = WeightedFramework::new();
227        wf.add_weighted_attack("a", "a", 0.5).unwrap();
228        // Self-attacking argument is never accepted under any budget
229        // (tolerating the attack leaves an isolated unattacked node,
230        // so it IS accepted at β ≥ 0.5). Let's verify the correct answer.
231        let min = min_budget_for_credulous(&wf, &"a").unwrap();
232        assert_eq!(min, Some(0.5));
233    }
234
235    #[test]
236    fn trajectory_for_independent_attackers_is_monotone() {
237        // Sanity check: with two independent attackers, acceptance is
238        // monotone in β. Under Dunne 2011 this holds in general (see
239        // uc3_chained_defense_is_monotone_under_dunne_semantics for the
240        // chained case); this test pins the simplest instance.
241        let mut wf = WeightedFramework::new();
242        wf.add_weighted_attack("a1", "target", 0.3).unwrap();
243        wf.add_weighted_attack("a2", "target", 0.5).unwrap();
244        let trajectory = acceptance_trajectory(&wf, &"target", AcceptanceMode::Credulous).unwrap();
245        let mut seen_accepted = false;
246        for p in trajectory {
247            if p.accepted {
248                seen_accepted = true;
249            } else {
250                assert!(
251                    !seen_accepted,
252                    "acceptance should be monotone non-decreasing in budget"
253                );
254            }
255        }
256    }
257
258    #[test]
259    fn credulous_trajectory_is_monotone_in_budget() {
260        let mut wf = WeightedFramework::new();
261        wf.add_weighted_attack("a", "b", 0.4).unwrap();
262        wf.add_weighted_attack("b", "c", 0.6).unwrap();
263        let budgets: Vec<Budget> = [0.0, 0.4, 1.0, 1.5]
264            .into_iter()
265            .map(|b| Budget::new(b).unwrap())
266            .collect();
267        let mut traj = Vec::new();
268        for budget in &budgets {
269            let accepted = is_credulously_accepted_at(&wf, &"c", *budget).unwrap();
270            traj.push(SweepPoint {
271                budget: budget.value(),
272                accepted,
273            });
274        }
275        // Monotone: once true at some β, remains true for all β' > β.
276        let first_true = traj.iter().position(|p| p.accepted);
277        if let Some(i) = first_true {
278            for p in &traj[i..] {
279                assert!(p.accepted, "credulous trajectory regressed at β={}", p.budget);
280            }
281        }
282    }
283
284    #[test]
285    fn min_budget_for_credulous_handles_sub_picosecond_weights() {
286        // Witness: weights far below 1e-12 must not be silently
287        // merged into the 0.0 breakpoint.
288        let mut wf = WeightedFramework::new();
289        wf.add_weighted_attack("a", "b", 1e-13).unwrap();
290        let min = min_budget_for_credulous(&wf, &"b").unwrap();
291        assert_eq!(min, Some(1e-13));
292    }
293
294    #[test]
295    fn min_budget_captures_non_cumulative_subset_sum_flip() {
296        // Witness: a↔x mutual attack + y attacking a.
297        // Dropping only y→a (β=0.5) leaves a↔x and makes a credulous.
298        // Under v0.1.0 cumulative-threshold: breakpoints only probe
299        // cumulative sums {0, 0.3, 0.6, 1.1}, so the flip at 0.5 is
300        // missed and min_budget returns 0.6.
301        // Under v0.2.0 Dunne: breakpoints enumerate all subset sums
302        // including 0.5, so min_budget correctly returns 0.5.
303        let mut wf = WeightedFramework::new();
304        wf.add_weighted_attack("a", "x", 0.3).unwrap();
305        wf.add_weighted_attack("x", "a", 0.3).unwrap();
306        wf.add_weighted_attack("y", "a", 0.5).unwrap();
307        let min = min_budget_for_credulous(&wf, &"a").unwrap();
308        assert!(min.is_some(), "a should be credulously accepted at some finite budget");
309        assert!((min.unwrap() - 0.5).abs() < 1e-9, "flip should occur at β=0.5, got {:?}", min);
310    }
311}