Skip to main content

ftui_harness/
hdd.rs

1#![forbid(unsafe_code)]
2
3//! Hierarchical Delta Debugging (HDD) for structured inputs.
4//!
5//! Recursively removes parts of structured input while a failure predicate
6//! still holds, producing a minimal failing test case. Applies to widget
7//! trees, event sequences, state configurations, or any [`Decomposable`]
8//! structure.
9//!
10//! # Algorithm
11//!
12//! 1. Try removing children in halves (binary search reduction).
13//! 2. If removing a half still triggers the predicate, keep that reduction.
14//! 3. Otherwise split into smaller groups and retry.
15//! 4. When no more children can be removed at the current level, recurse
16//!    into each remaining child.
17//!
18//! # Example
19//!
20//! ```rust
21//! use ftui_harness::hdd::{Decomposable, hdd_minimize};
22//!
23//! #[derive(Clone, Debug, PartialEq)]
24//! struct Tree {
25//!     label: &'static str,
26//!     children: Vec<Tree>,
27//! }
28//!
29//! impl Decomposable for Tree {
30//!     fn children(&self) -> Vec<Self> {
31//!         self.children.clone()
32//!     }
33//!     fn remove_child(&mut self, idx: usize) {
34//!         self.children.remove(idx);
35//!     }
36//!     fn replace_children(&mut self, new_children: Vec<Self>) {
37//!         self.children = new_children;
38//!     }
39//! }
40//!
41//! let tree = Tree {
42//!     label: "root",
43//!     children: vec![
44//!         Tree { label: "a", children: vec![] },
45//!         Tree { label: "b", children: vec![] },  // ← causes failure
46//!         Tree { label: "c", children: vec![] },
47//!     ],
48//! };
49//!
50//! // Predicate: fails when tree contains a child labeled "b"
51//! let minimal = hdd_minimize(tree, |t| {
52//!     t.children.iter().any(|c| c.label == "b")
53//! });
54//!
55//! assert_eq!(minimal.children.len(), 1);
56//! assert_eq!(minimal.children[0].label, "b");
57//! ```
58
59/// A structure that can be decomposed into children for delta debugging.
60///
61/// Implementors expose their child elements so the HDD algorithm can
62/// try removing subsets to find a minimal failing configuration.
63pub trait Decomposable: Clone {
64    /// Return a snapshot of the current children.
65    fn children(&self) -> Vec<Self>;
66
67    /// Remove the child at position `idx`.
68    ///
69    /// # Panics
70    ///
71    /// May panic if `idx` is out of bounds.
72    fn remove_child(&mut self, idx: usize);
73
74    /// Replace all children with `new_children`.
75    fn replace_children(&mut self, new_children: Vec<Self>);
76}
77
78/// Minimize a structured input using Hierarchical Delta Debugging.
79///
80/// Repeatedly removes subsets of children at each level of the tree
81/// while `predicate` still returns `true` (i.e., the failure still
82/// reproduces). Returns the smallest structure that still satisfies
83/// the predicate.
84///
85/// The predicate should return `true` when the failure is present
86/// (the "interesting" condition holds).
87pub fn hdd_minimize<T, F>(input: T, predicate: F) -> T
88where
89    T: Decomposable,
90    F: Fn(&T) -> bool,
91{
92    assert!(
93        predicate(&input),
94        "predicate must hold on the original input"
95    );
96    hdd_minimize_inner(input, &predicate)
97}
98
99fn hdd_minimize_inner<T, F>(mut input: T, predicate: &F) -> T
100where
101    T: Decomposable,
102    F: Fn(&T) -> bool,
103{
104    // Phase 1: minimize the set of children at this level using ddmin.
105    input = ddmin_children(input, predicate);
106
107    // Phase 2: recurse into each remaining child.
108    let mut children = input.children();
109    for i in 0..children.len() {
110        let minimized = hdd_minimize_inner(children[i].clone(), predicate);
111
112        // Try replacing this child with its minimized version.
113        let original = children[i].clone();
114        children[i] = minimized;
115
116        let mut candidate = input.clone();
117        candidate.replace_children(children.clone());
118
119        if predicate(&candidate) {
120            input = candidate;
121        } else {
122            // Minimized child broke the predicate; restore original.
123            children[i] = original;
124        }
125    }
126
127    input
128}
129
130/// Delta-debugging minimization of children at a single level.
131///
132/// Implements the ddmin algorithm: try removing halves, then quarters,
133/// etc. of the children list while the predicate holds.
134fn ddmin_children<T, F>(mut input: T, predicate: &F) -> T
135where
136    T: Decomposable,
137    F: Fn(&T) -> bool,
138{
139    let mut n = 2usize;
140
141    loop {
142        let children = input.children();
143        let len = children.len();
144
145        if len == 0 {
146            break;
147        }
148
149        let chunk_size = len.div_ceil(n);
150        let mut reduced = false;
151
152        // Try removing each chunk.
153        let mut i = 0;
154        while i < n {
155            let start = i * chunk_size;
156            let end = (start + chunk_size).min(len);
157            if start >= len {
158                break;
159            }
160
161            // Build candidate with chunk [start..end) removed.
162            let mut candidate = input.clone();
163            let remaining: Vec<T> = children
164                .iter()
165                .enumerate()
166                .filter(|(idx, _)| *idx < start || *idx >= end)
167                .map(|(_, c)| c.clone())
168                .collect();
169            candidate.replace_children(remaining);
170
171            if predicate(&candidate) {
172                input = candidate;
173                n = 2;
174                reduced = true;
175                break;
176            }
177            i += 1;
178        }
179
180        if reduced {
181            continue;
182        }
183
184        // Try keeping each chunk (complement removal).
185        // Only useful when there are at least 2 chunks.
186        if n > 1 {
187            let mut i = 0;
188            while i < n {
189                let start = i * chunk_size;
190                let end = (start + chunk_size).min(len);
191                if start >= len {
192                    break;
193                }
194
195                let kept_len = end - start;
196                // Skip if keeping this chunk doesn't reduce the size.
197                if kept_len >= len {
198                    i += 1;
199                    continue;
200                }
201
202                // Build candidate keeping only chunk [start..end).
203                let mut candidate = input.clone();
204                let kept: Vec<T> = children[start..end].to_vec();
205                candidate.replace_children(kept);
206
207                if predicate(&candidate) {
208                    input = candidate;
209                    n = 2;
210                    reduced = true;
211                    break;
212                }
213                i += 1;
214            }
215        }
216
217        if reduced {
218            continue;
219        }
220
221        // Increase granularity.
222        if n >= len {
223            break;
224        }
225        n = (n * 2).min(len);
226    }
227
228    input
229}
230
231// =========================================================================
232// Logged Minimization (proptest post-shrink integration)
233// =========================================================================
234
235use std::cell::RefCell;
236use std::fmt;
237
238/// A single reduction step recorded during logged minimization.
239#[derive(Clone, Debug)]
240pub struct ReductionStep {
241    /// Step number (0-indexed).
242    pub step: usize,
243    /// What phase produced this step.
244    pub phase: ReductionPhase,
245    /// Number of children before this step.
246    pub children_before: usize,
247    /// Number of children after this step.
248    pub children_after: usize,
249    /// Whether the predicate held (and the reduction was accepted).
250    pub accepted: bool,
251}
252
253/// The phase of HDD that produced a reduction step.
254#[derive(Clone, Copy, Debug, PartialEq, Eq)]
255pub enum ReductionPhase {
256    /// Removing a chunk of children (ddmin).
257    ChunkRemoval,
258    /// Keeping only a subset of children (ddmin complement).
259    ChunkRetention,
260    /// Recursive minimization of a child subtree.
261    ChildRecursion,
262}
263
264impl fmt::Display for ReductionPhase {
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        match self {
267            Self::ChunkRemoval => write!(f, "chunk_removal"),
268            Self::ChunkRetention => write!(f, "chunk_retention"),
269            Self::ChildRecursion => write!(f, "child_recursion"),
270        }
271    }
272}
273
274/// Result of a logged HDD minimization.
275#[derive(Clone, Debug)]
276pub struct MinimizationResult<T> {
277    /// The minimized input.
278    pub minimized: T,
279    /// Log of all reduction steps attempted.
280    pub steps: Vec<ReductionStep>,
281    /// Total predicate evaluations.
282    pub predicate_calls: usize,
283}
284
285impl<T> MinimizationResult<T> {
286    /// Serialize the reduction log as JSONL (one JSON object per line).
287    pub fn steps_to_jsonl(&self) -> String {
288        let mut out = String::new();
289        for step in &self.steps {
290            if !out.is_empty() {
291                out.push('\n');
292            }
293            out.push_str(&format!(
294                "{{\"step\":{},\"phase\":\"{}\",\"children_before\":{},\"children_after\":{},\"accepted\":{}}}",
295                step.step, step.phase, step.children_before, step.children_after, step.accepted
296            ));
297        }
298        out
299    }
300}
301
302/// Minimize a structured input using HDD, logging every reduction step.
303///
304/// This is the proptest post-shrink integration point. After proptest finds
305/// a counterexample and performs its own shrinking, pass the result to this
306/// function for further structural minimization with a full audit trail.
307///
308/// Returns a [`MinimizationResult`] containing the minimized input, a log
309/// of all reduction steps, and the total number of predicate evaluations.
310pub fn hdd_minimize_logged<T, F>(input: T, predicate: F) -> MinimizationResult<T>
311where
312    T: Decomposable,
313    F: Fn(&T) -> bool,
314{
315    let log = RefCell::new(Vec::new());
316    let call_count = RefCell::new(0usize);
317
318    let logging_predicate = |t: &T| -> bool {
319        *call_count.borrow_mut() += 1;
320        predicate(t)
321    };
322
323    assert!(
324        logging_predicate(&input),
325        "predicate must hold on the original input"
326    );
327
328    let minimized = hdd_logged_inner(input, &logging_predicate, &log);
329
330    MinimizationResult {
331        minimized,
332        steps: log.into_inner(),
333        predicate_calls: call_count.into_inner(),
334    }
335}
336
337fn hdd_logged_inner<T, F>(mut input: T, predicate: &F, log: &RefCell<Vec<ReductionStep>>) -> T
338where
339    T: Decomposable,
340    F: Fn(&T) -> bool,
341{
342    // Phase 1: ddmin with logging.
343    input = ddmin_children_logged(input, predicate, log);
344
345    // Phase 2: recurse into each remaining child.
346    let mut children = input.children();
347    for i in 0..children.len() {
348        let before_count = count_children_recursive(&children[i]);
349        let minimized = hdd_logged_inner(children[i].clone(), predicate, log);
350        let after_count = count_children_recursive(&minimized);
351
352        let original = children[i].clone();
353        children[i] = minimized;
354
355        let mut candidate = input.clone();
356        candidate.replace_children(children.clone());
357
358        let accepted = predicate(&candidate);
359
360        let step_num = log.borrow().len();
361        log.borrow_mut().push(ReductionStep {
362            step: step_num,
363            phase: ReductionPhase::ChildRecursion,
364            children_before: before_count,
365            children_after: if accepted { after_count } else { before_count },
366            accepted,
367        });
368
369        if accepted {
370            input = candidate;
371        } else {
372            children[i] = original;
373        }
374    }
375
376    input
377}
378
379fn ddmin_children_logged<T, F>(mut input: T, predicate: &F, log: &RefCell<Vec<ReductionStep>>) -> T
380where
381    T: Decomposable,
382    F: Fn(&T) -> bool,
383{
384    let mut n = 2usize;
385
386    loop {
387        let children = input.children();
388        let len = children.len();
389
390        if len == 0 {
391            break;
392        }
393
394        let chunk_size = len.div_ceil(n);
395        let mut reduced = false;
396
397        // Try removing each chunk.
398        let mut i = 0;
399        while i < n {
400            let start = i * chunk_size;
401            let end = (start + chunk_size).min(len);
402            if start >= len {
403                break;
404            }
405
406            let mut candidate = input.clone();
407            let remaining: Vec<T> = children
408                .iter()
409                .enumerate()
410                .filter(|(idx, _)| *idx < start || *idx >= end)
411                .map(|(_, c)| c.clone())
412                .collect();
413            let new_len = remaining.len();
414            candidate.replace_children(remaining);
415
416            let accepted = predicate(&candidate);
417
418            let step_num = log.borrow().len();
419            log.borrow_mut().push(ReductionStep {
420                step: step_num,
421                phase: ReductionPhase::ChunkRemoval,
422                children_before: len,
423                children_after: if accepted { new_len } else { len },
424                accepted,
425            });
426
427            if accepted {
428                input = candidate;
429                n = 2;
430                reduced = true;
431                break;
432            }
433            i += 1;
434        }
435
436        if reduced {
437            continue;
438        }
439
440        // Try keeping each chunk.
441        if n > 1 {
442            let mut i = 0;
443            while i < n {
444                let start = i * chunk_size;
445                let end = (start + chunk_size).min(len);
446                if start >= len {
447                    break;
448                }
449
450                let kept_len = end - start;
451                if kept_len >= len {
452                    i += 1;
453                    continue;
454                }
455
456                let mut candidate = input.clone();
457                let kept: Vec<T> = children[start..end].to_vec();
458                candidate.replace_children(kept);
459
460                let accepted = predicate(&candidate);
461
462                let step_num = log.borrow().len();
463                log.borrow_mut().push(ReductionStep {
464                    step: step_num,
465                    phase: ReductionPhase::ChunkRetention,
466                    children_before: len,
467                    children_after: if accepted { kept_len } else { len },
468                    accepted,
469                });
470
471                if accepted {
472                    input = candidate;
473                    n = 2;
474                    reduced = true;
475                    break;
476                }
477                i += 1;
478            }
479        }
480
481        if reduced {
482            continue;
483        }
484
485        if n >= len {
486            break;
487        }
488        n = (n * 2).min(len);
489    }
490
491    input
492}
493
494/// Count total children recursively (used for step logging).
495fn count_children_recursive<T: Decomposable>(node: &T) -> usize {
496    let children = node.children();
497    children.len() + children.iter().map(count_children_recursive).sum::<usize>()
498}
499
500// =========================================================================
501// Tests
502// =========================================================================
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[derive(Clone, Debug, PartialEq)]
509    struct Tree {
510        label: String,
511        children: Vec<Tree>,
512    }
513
514    impl Tree {
515        fn leaf(label: &str) -> Self {
516            Self {
517                label: label.to_string(),
518                children: vec![],
519            }
520        }
521
522        fn node(label: &str, children: Vec<Tree>) -> Self {
523            Self {
524                label: label.to_string(),
525                children,
526            }
527        }
528    }
529
530    impl Decomposable for Tree {
531        fn children(&self) -> Vec<Self> {
532            self.children.clone()
533        }
534
535        fn remove_child(&mut self, idx: usize) {
536            self.children.remove(idx);
537        }
538
539        fn replace_children(&mut self, new_children: Vec<Self>) {
540            self.children = new_children;
541        }
542    }
543
544    // Vec<T> is also decomposable (for event sequences).
545    // A Vec with 0 or 1 elements is a leaf (no further decomposition).
546    impl<T: Clone> Decomposable for Vec<T> {
547        fn children(&self) -> Vec<Self> {
548            if self.len() <= 1 {
549                return vec![];
550            }
551            self.iter().map(|item| vec![item.clone()]).collect()
552        }
553
554        fn remove_child(&mut self, idx: usize) {
555            self.remove(idx);
556        }
557
558        fn replace_children(&mut self, new_children: Vec<Self>) {
559            *self = new_children.into_iter().flatten().collect();
560        }
561    }
562
563    #[test]
564    fn single_child_preserved() {
565        let tree = Tree::node("root", vec![Tree::leaf("only")]);
566        let result = hdd_minimize(tree, |t| t.children.iter().any(|c| c.label == "only"));
567        assert_eq!(result.children.len(), 1);
568        assert_eq!(result.children[0].label, "only");
569    }
570
571    #[test]
572    fn removes_irrelevant_children() {
573        let tree = Tree::node(
574            "root",
575            vec![
576                Tree::leaf("a"),
577                Tree::leaf("b"),
578                Tree::leaf("trigger"),
579                Tree::leaf("c"),
580                Tree::leaf("d"),
581            ],
582        );
583
584        let result = hdd_minimize(tree, |t| t.children.iter().any(|c| c.label == "trigger"));
585
586        assert_eq!(result.children.len(), 1);
587        assert_eq!(result.children[0].label, "trigger");
588    }
589
590    #[test]
591    fn preserves_two_required_children() {
592        let tree = Tree::node(
593            "root",
594            vec![
595                Tree::leaf("a"),
596                Tree::leaf("needed1"),
597                Tree::leaf("b"),
598                Tree::leaf("needed2"),
599                Tree::leaf("c"),
600            ],
601        );
602
603        let result = hdd_minimize(tree, |t| {
604            let labels: Vec<&str> = t.children.iter().map(|c| c.label.as_str()).collect();
605            labels.contains(&"needed1") && labels.contains(&"needed2")
606        });
607
608        assert_eq!(result.children.len(), 2);
609        let labels: Vec<&str> = result.children.iter().map(|c| c.label.as_str()).collect();
610        assert!(labels.contains(&"needed1"));
611        assert!(labels.contains(&"needed2"));
612    }
613
614    #[test]
615    fn recurses_into_children() {
616        let tree = Tree::node(
617            "root",
618            vec![Tree::node(
619                "parent",
620                vec![Tree::leaf("x"), Tree::leaf("culprit"), Tree::leaf("y")],
621            )],
622        );
623
624        let result = hdd_minimize(tree, |t| {
625            fn has_culprit(t: &Tree) -> bool {
626                if t.label == "culprit" {
627                    return true;
628                }
629                t.children.iter().any(has_culprit)
630            }
631            has_culprit(t)
632        });
633
634        // Root should have 1 child ("parent"), which has 1 child ("culprit").
635        assert_eq!(result.children.len(), 1);
636        assert_eq!(result.children[0].label, "parent");
637        assert_eq!(result.children[0].children.len(), 1);
638        assert_eq!(result.children[0].children[0].label, "culprit");
639    }
640
641    #[test]
642    fn empty_children_is_fixpoint() {
643        let tree = Tree::leaf("root");
644        let result = hdd_minimize(tree.clone(), |_| true);
645        assert_eq!(result, tree);
646    }
647
648    #[test]
649    fn event_sequence_minimization() {
650        let events: Vec<i32> = vec![1, 2, 3, 4, 5, 6, 7, 8];
651
652        // Predicate: sequence must contain 3 and 7.
653        let result = hdd_minimize(events, |seq| seq.contains(&3) && seq.contains(&7));
654
655        assert!(result.contains(&3));
656        assert!(result.contains(&7));
657        assert!(result.len() <= 3); // Should be close to minimal.
658    }
659
660    #[test]
661    fn deep_nested_minimization() {
662        let tree = Tree::node(
663            "root",
664            vec![
665                Tree::node(
666                    "branch1",
667                    vec![
668                        Tree::leaf("noise1"),
669                        Tree::node(
670                            "sub",
671                            vec![Tree::leaf("noise2"), Tree::leaf("deep_trigger")],
672                        ),
673                    ],
674                ),
675                Tree::leaf("noise3"),
676            ],
677        );
678
679        let result = hdd_minimize(tree, |t| {
680            fn find_label(t: &Tree, label: &str) -> bool {
681                if t.label == label {
682                    return true;
683                }
684                t.children.iter().any(|c| find_label(c, label))
685            }
686            find_label(t, "deep_trigger")
687        });
688
689        // Should drill down to the minimal path.
690        fn find_label(t: &Tree, label: &str) -> bool {
691            if t.label == label {
692                return true;
693            }
694            t.children.iter().any(|c| find_label(c, label))
695        }
696        assert!(find_label(&result, "deep_trigger"));
697
698        // Count total nodes — should be much less than original 7.
699        fn count_nodes(t: &Tree) -> usize {
700            1 + t.children.iter().map(count_nodes).sum::<usize>()
701        }
702        assert!(count_nodes(&result) <= 4);
703    }
704
705    #[test]
706    #[should_panic(expected = "predicate must hold")]
707    fn panics_if_predicate_fails_on_input() {
708        let tree = Tree::leaf("root");
709        hdd_minimize(tree, |_| false);
710    }
711
712    #[test]
713    fn all_children_needed() {
714        let tree = Tree::node(
715            "root",
716            vec![Tree::leaf("a"), Tree::leaf("b"), Tree::leaf("c")],
717        );
718
719        let result = hdd_minimize(tree.clone(), |t| t.children.len() == 3);
720
721        assert_eq!(result.children.len(), 3);
722    }
723
724    #[test]
725    fn large_input_binary_search_efficiency() {
726        // With 100 children, ddmin should not need O(n) predicate calls.
727        let children: Vec<Tree> = (0..100).map(|i| Tree::leaf(&format!("n{i}"))).collect();
728        let tree = Tree::node("root", children);
729
730        let call_count = std::cell::Cell::new(0u64);
731        let result = hdd_minimize(tree, |t| {
732            call_count.set(call_count.get() + 1);
733            t.children.iter().any(|c| c.label == "n42")
734        });
735
736        assert_eq!(result.children.len(), 1);
737        assert_eq!(result.children[0].label, "n42");
738
739        // ddmin on n=100 with 1 target should need O(log n) calls, not O(n).
740        // Generous bound: should be well under 100.
741        assert!(
742            call_count.get() < 50,
743            "too many predicate calls: {}",
744            call_count.get()
745        );
746    }
747
748    // =====================================================================
749    // Logged minimization tests
750    // =====================================================================
751
752    #[test]
753    fn logged_minimization_produces_steps() {
754        let tree = Tree::node(
755            "root",
756            vec![
757                Tree::leaf("a"),
758                Tree::leaf("trigger"),
759                Tree::leaf("b"),
760                Tree::leaf("c"),
761            ],
762        );
763
764        let result = hdd_minimize_logged(tree, |t| t.children.iter().any(|c| c.label == "trigger"));
765
766        assert_eq!(result.minimized.children.len(), 1);
767        assert_eq!(result.minimized.children[0].label, "trigger");
768        assert!(!result.steps.is_empty(), "should have logged steps");
769        assert!(result.predicate_calls > 0);
770    }
771
772    #[test]
773    fn logged_steps_contain_accepted_reductions() {
774        let tree = Tree::node(
775            "root",
776            vec![
777                Tree::leaf("a"),
778                Tree::leaf("b"),
779                Tree::leaf("trigger"),
780                Tree::leaf("c"),
781                Tree::leaf("d"),
782            ],
783        );
784
785        let result = hdd_minimize_logged(tree, |t| t.children.iter().any(|c| c.label == "trigger"));
786
787        // At least one step should have been accepted.
788        let accepted_count = result.steps.iter().filter(|s| s.accepted).count();
789        assert!(
790            accepted_count > 0,
791            "at least one reduction must be accepted"
792        );
793
794        // Accepted steps should show actual reduction.
795        for step in result.steps.iter().filter(|s| s.accepted) {
796            assert!(
797                step.children_after <= step.children_before,
798                "accepted step should not increase children"
799            );
800        }
801    }
802
803    #[test]
804    fn jsonl_output_is_valid() {
805        let tree = Tree::node(
806            "root",
807            vec![Tree::leaf("a"), Tree::leaf("trigger"), Tree::leaf("b")],
808        );
809
810        let result = hdd_minimize_logged(tree, |t| t.children.iter().any(|c| c.label == "trigger"));
811
812        let jsonl = result.steps_to_jsonl();
813        assert!(!jsonl.is_empty());
814
815        // Each line should be valid JSON.
816        for line in jsonl.lines() {
817            let parsed: serde_json::Value =
818                serde_json::from_str(line).expect("each JSONL line must be valid JSON");
819            assert!(parsed.get("step").is_some());
820            assert!(parsed.get("phase").is_some());
821            assert!(parsed.get("accepted").is_some());
822        }
823    }
824
825    #[test]
826    fn logged_predicate_count_matches() {
827        let tree = Tree::node(
828            "root",
829            vec![Tree::leaf("a"), Tree::leaf("trigger"), Tree::leaf("b")],
830        );
831
832        let manual_count = std::cell::Cell::new(0u64);
833        let result = hdd_minimize_logged(tree, |t| {
834            manual_count.set(manual_count.get() + 1);
835            t.children.iter().any(|c| c.label == "trigger")
836        });
837
838        // The logged predicate_calls should match our manual count.
839        assert_eq!(result.predicate_calls, manual_count.get() as usize);
840    }
841}