1use 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#[derive(Debug, Clone, PartialEq)]
25pub struct SweepPoint {
26 pub budget: f64,
28 pub accepted: bool,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum AcceptanceMode {
35 Credulous,
37 Skeptical,
39}
40
41fn 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 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 if scale > 1e-100 {
83 diff < 1e-9 * scale
84 } else {
85 diff < 1e-100
86 }
87 });
88 sums
89}
90
91pub 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
121pub 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
145pub 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 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 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 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 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 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 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 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 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 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}