Skip to main content

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}