recon_sim/shrink.rs
1//! Reducing a failing scenario to a smaller one that still fails.
2//!
3//! This is the thing a deterministic simulator can do that a black-box fault injector cannot. A
4//! run here is a function of its inputs, so a candidate reduction can be *run* and the question
5//! "does it still fail?" answered rather than estimated.
6//!
7//! # A reduced scenario is a different run, not the same one made smaller
8//!
9//! Worth stating first, because it is what a reader is most likely to assume wrongly. Removing a
10//! step changes when every later message is drawn from the run's generator, so the result does not
11//! replay a prefix of the original: it is a **new run that also satisfies the predicate**. The seed
12//! is held fixed so the reduction is reproducible, not because the stream is preserved — it is not.
13//!
14//! Two things follow. Every candidate must be re-run rather than reasoned about, which is what this
15//! module does. And a reduction can legitimately land on a scenario that fails for a *different*
16//! reason than the original. The defence is the predicate: name the property, not the symptom, and
17//! the report says which predicate was used.
18//!
19//! # What it reduces, and in what order
20//!
21//! Cheapest and most informative first, then round again to a fixed point:
22//!
23//! 1. **The horizon**, by binary search down to the earliest that still fails — the reduction that
24//! answers *when*, which is where a hand-written probe starts.
25//! 2. **Steps**, by delta-debugging rather than one-at-a-time deletion. Faults here interact: a
26//! crash matters only with the partition that isolates its quorum, and removing either alone
27//! often stops the failure where removing both would not have been tried.
28//! 3. **Fault detail** — a partition with fewer groups.
29//! 4. **Membership**, last, because dropping a process changes quorum arithmetic. A bug that does
30//! not survive it is not a bug about that process; one that does is a much better
31//! counterexample.
32
33use crate::scenario::{Scenario, Step};
34use crate::{Config, Sim};
35use core::fmt::Write as _;
36use core::time::Duration;
37use recon_core::{NodeId, Protocol};
38
39/// How large a scenario is, in the terms the search reduces.
40///
41/// Every reduction the search accepts leaves this no larger in any component and strictly smaller
42/// in one, which is what makes the loop terminate.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct Size {
45 pub nodes: usize,
46 pub steps: usize,
47 pub horizon: Duration,
48 /// Groups across every partition step — the only fault with internal structure to simplify.
49 pub groups: usize,
50}
51
52impl Size {
53 fn of<C>(s: &Scenario<C>) -> Size {
54 Size {
55 nodes: s.nodes.len(),
56 steps: s.steps.len(),
57 horizon: s.horizon,
58 groups: s
59 .steps
60 .iter()
61 .map(|(_, st)| match st {
62 Step::Partition(g) => g.len(),
63 _ => 0,
64 })
65 .sum(),
66 }
67 }
68}
69
70/// What a reduction found, and what it cost.
71#[derive(Debug, Clone)]
72pub struct Reduction<C> {
73 /// The smallest scenario found. It satisfies the predicate: it was run and checked.
74 pub scenario: Scenario<C>,
75 /// What the predicate was called. Recorded because a reduction can legitimately land on a
76 /// different failure than the one it started from, and the reader needs to know what was
77 /// actually being hunted.
78 pub predicate: String,
79 /// How many candidates were run, the original included.
80 pub candidates: usize,
81 pub before: Size,
82 pub after: Size,
83}
84
85impl<C> Reduction<C> {
86 /// Whether the search found anything to remove.
87 pub fn reduced(&self) -> bool {
88 self.before != self.after
89 }
90
91 /// A summary for a test to print when it fails.
92 pub fn report(&self) -> String {
93 let mut s = String::new();
94 let _ = writeln!(s, "shrunk against predicate `{}`", self.predicate);
95 let _ = writeln!(
96 s,
97 " steps {} -> {}, nodes {} -> {}, horizon {:?} -> {:?}",
98 self.before.steps,
99 self.after.steps,
100 self.before.nodes,
101 self.after.nodes,
102 self.before.horizon,
103 self.after.horizon
104 );
105 let _ = writeln!(s, " {} candidates run", self.candidates);
106 if !self.reduced() {
107 let _ = writeln!(s, " nothing came out — the scenario was already minimal");
108 }
109 s
110 }
111}
112
113impl<C: core::fmt::Debug> Reduction<C> {
114 /// The reduced scenario as Rust that reconstructs it, under the report.
115 pub fn to_rust(&self, name: &str) -> String {
116 format!("{}\n{}", self.report(), self.scenario.to_rust(name))
117 }
118}
119
120/// How precisely the horizon search narrows. Twenty runs for a one-second horizon; narrowing
121/// further costs a run per halving and buys a counterexample nobody reads differently.
122const HORIZON_RESOLUTION: Duration = Duration::from_micros(1);
123
124/// Search for a smaller scenario whose run still satisfies `predicate`.
125///
126/// `build` is handed the candidate's configuration and membership and returns a simulator over
127/// them, exactly as for [`Sim::run_scenario`] — it is called once per candidate, so it must be
128/// able to construct a fresh run each time. `predicate` is evaluated on the finished run.
129///
130/// `predicate_name` is carried into the report. Name the property, not the symptom.
131///
132/// # Panics
133///
134/// If the original scenario does not satisfy the predicate. There is then nothing to reduce, and
135/// returning something would mean returning a scenario that does not fail — which is the one
136/// outcome worse than returning the original.
137///
138/// The predicate itself must be total: return `false` for a run that does not exhibit what you are
139/// hunting, and do not assert. A predicate that panics makes the search unable to reject a
140/// candidate.
141pub fn shrink<P>(
142 scenario: &Scenario<P::Cmd>,
143 predicate_name: &str,
144 build: impl Fn(Config, &[NodeId]) -> Sim<P>,
145 predicate: impl Fn(&Sim<P>) -> bool,
146) -> Reduction<P::Cmd>
147where
148 P: Protocol,
149 P::Cmd: Clone,
150 P::Msg: Clone + PartialEq,
151 P::Ind: Clone,
152 P::Meta: Clone,
153 P::Entry: Clone,
154{
155 let mut candidates = 0;
156 let mut holds = |s: &Scenario<P::Cmd>| {
157 candidates += 1;
158 predicate(&Sim::run_scenario(s, |c, n| build(c, n)))
159 };
160
161 assert!(
162 holds(scenario),
163 "shrink: the original scenario does not satisfy `{predicate_name}` — nothing to reduce"
164 );
165
166 let before = Size::of(scenario);
167 let mut best = scenario.clone();
168 loop {
169 let mark = Size::of(&best);
170 best = shrink_horizon(best, &mut holds);
171 best = shrink_steps(best, &mut holds);
172 best = simplify_faults(best, &mut holds);
173 best = shrink_membership(best, &mut holds);
174 if Size::of(&best) == mark {
175 break;
176 }
177 }
178
179 let after = Size::of(&best);
180 Reduction { scenario: best, predicate: predicate_name.to_string(), candidates, before, after }
181}
182
183fn nanos(d: Duration) -> u64 {
184 u64::try_from(d.as_nanos()).unwrap_or(u64::MAX)
185}
186
187/// Binary search for the earliest horizon that still fails.
188///
189/// Assumes that a predicate true at some horizon stays true at a longer one — which holds for
190/// "something happened" predicates, the kind worth hunting. Where it does not, the search still
191/// returns a horizon at which the predicate holds, because every accepted candidate was run; it
192/// just may not be the earliest.
193fn shrink_horizon<C: Clone>(
194 scenario: Scenario<C>,
195 holds: &mut impl FnMut(&Scenario<C>) -> bool,
196) -> Scenario<C> {
197 let mut lo = 0u64;
198 let mut hi = nanos(scenario.horizon);
199 if hi == 0 {
200 return scenario;
201 }
202 let mut best = scenario;
203 while hi - lo > nanos(HORIZON_RESOLUTION) {
204 let mid = lo + (hi - lo) / 2;
205 let candidate = best.with_horizon(Duration::from_nanos(mid));
206 if holds(&candidate) {
207 hi = mid;
208 best = candidate;
209 } else {
210 lo = mid;
211 }
212 }
213 best
214}
215
216/// Delta-debugging: `ddmin` over the step list.
217///
218/// One-at-a-time deletion is not enough here. A crash matters only together with the partition
219/// that isolates its quorum, so removing either alone stops the failure and the pair is never
220/// tried. `ddmin` tries complements as well as chunks, at increasing granularity, which finds it.
221fn shrink_steps<C: Clone>(
222 scenario: Scenario<C>,
223 holds: &mut impl FnMut(&Scenario<C>) -> bool,
224) -> Scenario<C> {
225 let mut best = scenario;
226 let mut n = 2usize;
227 loop {
228 let len = best.steps.len();
229 if len < 2 {
230 return best;
231 }
232 let n_now = n.min(len);
233 let bounds: Vec<(usize, usize)> = (0..n_now)
234 .map(|i| (len * i / n_now, len * (i + 1) / n_now))
235 .filter(|(a, b)| a < b)
236 .collect();
237
238 // Reduce to a subset: keep one chunk, drop everything else.
239 let mut progressed = false;
240 for &(a, b) in &bounds {
241 let drop: Vec<usize> = (0..len).filter(|i| *i < a || *i >= b).collect();
242 if drop.is_empty() {
243 continue;
244 }
245 let candidate = best.without_steps(&drop);
246 if holds(&candidate) {
247 best = candidate;
248 n = 2;
249 progressed = true;
250 break;
251 }
252 }
253 if progressed {
254 continue;
255 }
256
257 // Reduce the complement: drop one chunk, keep everything else.
258 for &(a, b) in &bounds {
259 let drop: Vec<usize> = (a..b).collect();
260 let candidate = best.without_steps(&drop);
261 if holds(&candidate) {
262 best = candidate;
263 n = (n_now - 1).max(2);
264 progressed = true;
265 break;
266 }
267 }
268 if progressed {
269 continue;
270 }
271
272 if n_now >= len {
273 return best;
274 }
275 n = (n_now * 2).min(len);
276 }
277}
278
279/// Simplify the faults that have internal structure to simplify.
280///
281/// Only a partition does: everything else the simulator can be told to do is atomic, and is
282/// reduced by being deleted. Merging two groups makes the partition less severe — strictly fewer
283/// pairs cut — without changing when it happens or who is in the run.
284fn simplify_faults<C: Clone>(
285 scenario: Scenario<C>,
286 holds: &mut impl FnMut(&Scenario<C>) -> bool,
287) -> Scenario<C> {
288 let mut best = scenario;
289 let mut i = 0;
290 while i < best.steps.len() {
291 let Step::Partition(groups) = &best.steps[i].1 else {
292 i += 1;
293 continue;
294 };
295 if groups.len() < 2 {
296 i += 1;
297 continue;
298 }
299 let mut merged_any = false;
300 'pairs: for a in 0..groups.len() {
301 for b in (a + 1)..groups.len() {
302 let mut next: Vec<Vec<NodeId>> = Vec::new();
303 for (k, g) in groups.iter().enumerate() {
304 if k == b {
305 continue;
306 }
307 if k == a {
308 let mut joined = g.clone();
309 joined.extend(groups[b].iter().copied());
310 next.push(joined);
311 } else {
312 next.push(g.clone());
313 }
314 }
315 let candidate = best.with_step(i, Step::Partition(next));
316 if holds(&candidate) {
317 best = candidate;
318 merged_any = true;
319 break 'pairs;
320 }
321 }
322 }
323 if !merged_any {
324 i += 1;
325 }
326 }
327 best
328}
329
330/// Drop a process, and every step that named it.
331///
332/// Last, and deliberately: this changes quorum arithmetic, so most bugs will not survive it. One
333/// that does is a counterexample about the algorithm rather than about the run's size.
334fn shrink_membership<C: Clone>(
335 scenario: Scenario<C>,
336 holds: &mut impl FnMut(&Scenario<C>) -> bool,
337) -> Scenario<C> {
338 let mut best = scenario;
339 let mut i = 0;
340 while i < best.nodes.len() {
341 let node = best.nodes[i];
342 let candidate = best.without_node(node);
343 if holds(&candidate) {
344 best = candidate;
345 } else {
346 i += 1;
347 }
348 }
349 best
350}