Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • Part 0 · What Debugging Is6
  • Part 1 · Reproduce8
  • Part 2 · Observe9
  • Part 3 · Localise10
  • Part 4 · Hypothesise and Test6
  • Part 5 · Bug Classes16
  • Part 6 · Debug by Stage9
  • Part 7 · Hangs and Deadlocks7
  • Part 8 · Intermittent Failures7
    • Why Intermittents Happen
    • Making the Rate Go Up
    • Capturing the Rare Event
    • Statistics of Rare Failures
    • Ordering-Dependent Bugs
    • Heisenbugs
    • An Intermittent on dbglab
  • Part 9 · Fix, Prove, Prevent7
  • Part 10 · Case Studies12
  • Part 11 · Debug as a Team5
  • Part 12 · Interview Prep5
Loading lesson

‹Part 8 · Intermittent Failures
  1. Topics
  2. The Art of Hardware Debug
  3. Part 8 · Intermittent Failures
  4. Statistics of Rare Failures

Part 8 · Intermittent Failures · Senior (S)

Statistics of Rare Failures

How many clean runs show a fix works, to a stated confidence: the exact bound and the rule of three, intervals on a measured rate, and a test for whether two rates really differ. A small self-checking Python calculator, applied to dbglab's B4 and to the FIFO bug.

On this page

    Why this matters

    "It passed 20 times, so it is fixed" is how a bug that fails one run in ten ships: twenty clean runs happen by luck one time in eight. A two-line calculation says how many clean runs you need, and what a clean sweep does and does not rule out.

    After this lesson you can: calculate how many clean runs show a fix to a stated confidence, bound the rate left after a clean sweep, put an interval on a measured rate, and test whether two rates differ.

    Note

    Level: Senior (S) · Debug-loop step: Prove

    Before this: Making the Rate Go Up, Making a Flaky Test Fail

    Twenty green runs and a release on Friday

    A fix for dbglab's hidden X (B4) is in review. Its author ran the random test on seeds 1 to 20 and all twenty passed. Before the fix, the measured rate was 100 failures in 1,000 seeds, about 10%.

    The reviewer asks whether twenty is enough. The author says twenty clean runs is a lot. Who is right, and how many runs would settle it?

    What people usually do, and why it fails

    Teams pick a round number of reruns, ten or twenty or a nightly's worth, and call the bug fixed when they are all green. Whether that number means anything depends entirely on the rate of the bug. For a bug that fails one run in ten, twenty clean runs happen by pure luck 12% of the time (0.9²⁰). For a bug at one in a thousand, twenty clean runs happen 98% of the time whether or not the fix works.

    The opposite mistake is just as common: a failure rate that drops from 3 in 300 to 0 in 100 is reported as "fixed". With so few failures, those two results are not distinguishable.

    The method: four questions, four formulas

    Assume each run fails independently with the same probability p, which is close to true for seeds of one bench setting.

    1. How many runs to see the bug at least once? The chance that n runs all pass is (1 − p)ⁿ. For confidence c, solve (1 − p)ⁿ ≤ 1 − c.

    2. After n clean runs, how high could the rate still be? The exact one-sided bound at confidence c is 1 − (1 − c)^(1/n). At 95% it is very close to 3/n: the rule of three.

    3. What does a measured rate k/n really say? Give an interval, not a point. The exact (Clopper-Pearson) interval comes from the binomial distribution.

    4. Did a change really move the rate? Compare k₁/n₁ with k₂/n₂ with Fisher's exact test. A small p-value says the difference is unlikely to be luck.

    n≥ln(1−p)ln(1−c)​≈p3​ at c=95%,pupper​=1−(1−c)1/n≈n3​

    Both halves of the rule of three come from ln(0.05) ≈ −3. They are the same statement read in two directions: about 3/p runs to catch a rate-p bug, and a bound of about 3/n after n clean runs.

    The picture: confidence against runs

    The chance of seeing a bug at least once in n runs, for failure rates of 10%, 1% and 0.1%. Each curve crosses 95% at about 3/p runs: 29, 299 and 2,995.

    Every factor of ten in rarity costs a factor of ten in runs. That is why the previous lessons raise the rate first: the proof gets cheaper by the same factor.

    Do it: a calculator that checks itself

    About a hundred lines, standard library only. It prints the tables the method needs, applies them to rates measured earlier in this course, and checks its own results against values worked by hand.

    example.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    # rare.py: the arithmetic of rare failures. Standard library only; checks itself and prints PASS.
    import math
    import sys
    
    
    def p_seen(p, n):
        """Chance that n independent runs show a bug that fails each run with probability p."""
        return 1.0 - (1.0 - p) ** n
    
    
    def runs_needed(p, conf):
        """Fewest runs that show a rate-p bug at least once with probability conf."""
        return math.ceil(math.log(1.0 - conf) / math.log(1.0 - p))
    
    
    def upper_if_clean(n, conf):
        """Exact one-sided upper bound on the failure rate after n runs with no failure."""
        return 1.0 - (1.0 - conf) ** (1.0 / n)
    
    
    def binom_pmf(k, n, p):
        if p <= 0.0:
            return 1.0 if k == 0 else 0.0
        if p >= 1.0:
            return 1.0 if k == n else 0.0
        log = (math.lgamma(n + 1) - math.lgamma(k + 1) - math.lgamma(n - k + 1)
               + k * math.log(p) + (n - k) * math.log(1.0 - p))
        return math.exp(log)
    
    
    def binom_cdf(k, n, p):
        return sum(binom_pmf(i, n, p) for i in range(k + 1))
    
    
    def solve(f, target, lo=0.0, hi=1.0):
        """Bisection for an increasing f on [lo, hi]."""
        for _ in range(100):
            mid = (lo + hi) / 2
            if f(mid) < target:
                lo = mid
            else:
                hi = mid
        return (lo + hi) / 2
    
    
    def exact_interval(k, n, conf=0.95):
        """Clopper-Pearson two-sided interval for k failures in n runs."""
        a = (1.0 - conf) / 2
        lo = 0.0 if k == 0 else solve(lambda p: 1.0 - binom_cdf(k - 1, n, p), a)
        hi = 1.0 if k == n else solve(lambda p: -binom_cdf(k, n, p), -a)
        return lo, hi
    
    
    def fisher(k1, n1, k2, n2):
        """Two-sided Fisher exact test: is k1/n1 really different from k2/n2?"""
        total_k, total_n = k1 + k2, n1 + n2
    
        def hyper(x):
            return math.exp(math.lgamma(total_k + 1) - math.lgamma(x + 1) - math.lgamma(total_k - x + 1)
                            + math.lgamma(total_n - total_k + 1) - math.lgamma(n1 - x + 1)
                            - math.lgamma(total_n - total_k - n1 + x + 1)
                            - (math.lgamma(total_n + 1) - math.lgamma(n1 + 1) - math.lgamma(n2 + 1)))
    
        lo, hi = max(0, n1 - (total_n - total_k)), min(n1, total_k)
        seen = hyper(k1)
        return min(1.0, sum(hyper(x) for x in range(lo, hi + 1) if hyper(x) <= seen * (1 + 1e-9)))
    
    
    def check(cond, what):
        if not cond:
            print("FAIL:", what)
            sys.exit(1)
    
    
    # 1. How many runs does it take to see a bug of rate p at least once?
    print("runs needed to see the bug at least once")
    print("  rate      95%     99%")
    for p in (0.10, 0.013, 0.01, 0.001):
        print(f"  {p:6.3%} {runs_needed(p, 0.95):6d} {runs_needed(p, 0.99):7d}")
    
    # 2. After n clean runs, how high could the rate still be? (exact, and the rule of three)
    print("clean runs and what they rule out (95%, one-sided)")
    for n in (10, 29, 100, 300, 1000, 3000):
        print(f"  0 in {n:4d}: rate below {upper_if_clean(n, 0.95):6.2%}   rule of three: {3 / n:6.2%}")
    
    # 3. dbglab's B4, as measured: 100 failures in 1,000 seeds; stressed: 177 in 200
    lo, hi = exact_interval(100, 1000)
    print(f"B4, 100 in 1000: 10.0%, exact 95% interval {lo:.2%} to {hi:.2%}")
    lo2, hi2 = exact_interval(177, 200)
    print(f"B4 stressed, 177 in 200: 88.5%, exact 95% interval {lo2:.1%} to {hi2:.1%}")
    print(f"  clean runs to rule B4 out at 95%: {runs_needed(0.10, 0.95)} default, "
          f"{runs_needed(lo2, 0.95)} stressed (using the interval's low end, {lo2:.1%})")
    
    # the FIFO writer bug of the previous lessons: 13 in 1,000 nightly seeds, 196 in 200 with bursty stalls
    lo3, hi3 = exact_interval(196, 200)
    print(f"FIFO bug, 13 in 1000 nightly: {runs_needed(0.013, 0.95)} clean runs to rule out; "
          f"196 in 200 stressed (low end {lo3:.1%}): {runs_needed(lo3, 0.95)}")
    
    # 4. Is one rate really different from another?
    for k1, n1, k2, n2, what in ((21, 200, 21, 200, "default vs +OPS=6000"),
                                 (21, 200, 177, 200, "default vs biased zero fills"),
                                 (100, 1000, 0, 1000, "before vs after a fix"),
                                 (3, 300, 0, 100, "3 in 300 before, 0 in 100 after"),
                                 (12, 400, 4, 400, "12 in 400 before, 4 in 400 after")):
        print(f"  {what}: {k1}/{n1} vs {k2}/{n2}, p = {fisher(k1, n1, k2, n2):.3g}")
    
    # self-checks against values worked by hand or known from the literature
    check(runs_needed(0.10, 0.95) == 29 and runs_needed(0.10, 0.99) == 44, "runs for a 10% bug")
    check(runs_needed(0.001, 0.95) == 2995, "runs for a 0.1% bug")
    check(abs(upper_if_clean(1000, 0.95) - 0.002991) < 1e-5, "upper bound after 1000 clean runs")
    check(all(abs(upper_if_clean(n, 0.95) * n / 3 - 1) < 0.05 for n in (100, 1000, 10000)), "rule of three")
    check(abs(p_seen(0.10, 29) - 0.953) < 1e-3, "chance of seeing a 10% bug in 29 runs")
    check(abs(lo - 0.0821) < 5e-4 and abs(hi - 0.1203) < 5e-4, "exact interval for 100 in 1000")
    check(abs(binom_cdf(1000, 1000, 0.3) - 1.0) < 1e-9, "binomial sums to one")
    check(fisher(21, 200, 21, 200) > 0.99, "equal rates are not different")
    check(fisher(100, 1000, 0, 1000) < 1e-20, "10% vs 0% over 1000 runs each")
    check(fisher(3, 300, 0, 100) > 0.5, "3 in 300 vs 0 in 100 proves nothing")
    check(runs_needed(lo3, 0.95) == 2, "two clean runs rule out a 95% bug")
    print("PASS: rare-failure arithmetic checks out")
    Terminal
    $ python3 -I -B rare.py
    runs needed to see the bug at least once
      rate      95%     99%
      10.000%     29      44
      1.300%    229     352
      1.000%    299     459
      0.100%   2995    4603
    clean runs and what they rule out (95%, one-sided)
      0 in   10: rate below 25.89%   rule of three: 30.00%
      0 in   29: rate below  9.81%   rule of three: 10.34%
      0 in  100: rate below  2.95%   rule of three:  3.00%
      0 in  300: rate below  0.99%   rule of three:  1.00%
      0 in 1000: rate below  0.30%   rule of three:  0.30%
      0 in 3000: rate below  0.10%   rule of three:  0.10%
    B4, 100 in 1000: 10.0%, exact 95% interval 8.21% to 12.03%
    B4 stressed, 177 in 200: 88.5%, exact 95% interval 83.2% to 92.6%
      clean runs to rule B4 out at 95%: 29 default, 2 stressed (using the interval's low end, 83.2%)
    FIFO bug, 13 in 1000 nightly: 229 clean runs to rule out; 196 in 200 stressed (low end 95.0%): 2
      default vs +OPS=6000: 21/200 vs 21/200, p = 1
      default vs biased zero fills: 21/200 vs 177/200, p = 2.42e-61
      before vs after a fix: 100/1000 vs 0/1000, p = 1.17e-31
      3 in 300 before, 0 in 100 after: 3/300 vs 0/100, p = 0.576
      12 in 400 before, 4 in 400 after: 12/400 vs 4/400, p = 0.0739
    PASS: rare-failure arithmetic checks out

    Read it against the scene

    • Twenty is not enough. If nothing had changed, a 10% bug would still give twenty clean runs 12% of the time. To push that below 5% takes 29 clean runs, and 44 to push it below 1%.

    • The stressed repro is far cheaper. With the zero-fill bias B4 failed 177 of 200 seeds. Even taking the low end of its interval, 83.2%, two clean runs rule it out at 95%. Prove on the stressed setting first, then on the default.

    • A clean sweep bounds the bug. 1,000 clean seeds would say the rate is below 0.30%. It cannot say zero, and it cannot say the cause is gone; only the mechanism can.

    • Measured rates have width. "10%" from 100 in 1,000 means 8.2% to 12.0%. (Part 1 quoted the Wilson interval, 8.3% to 12.0%. The exact Clopper-Pearson interval is slightly wider because it never undercovers.) Part 1's blocks of 100 seeds ranged from 5% to 16%, all compatible with one rate.

    • Rates that look different may not be. Running longer left B4 at 21 in 200 (p = 1, no change); the bias moved it to 177 in 200 (p = 2.4 × 10⁻⁶¹, certainly real). And 3 in 300 before against 0 in 100 after has p = 0.58: no evidence of a fix at all.

    The FIFO writer bug gives the same lesson in numbers: at its nightly 1.3% it takes 229 clean runs to rule out; under bursty stalls (196 of 200, at least 95.0% per run) it takes 2. Its fix passed 200 runs of the hardest stress, a hundred times more than it needed.

    What the evidence says

    You are on the Prove step of the debug loop. Its output is a sentence with numbers in it: "the stressed repro passed 200 of 200, which rules out the old 88% rate at far beyond 95%, and 1,000 fresh default seeds passed, so any remaining rate is below 0.3%". The next case runs this kind of proof on B4.

    Traps

    Common pitfall

    Reusing the seeds that measured the bug. Seeds are deterministic. Rerunning seeds 1 to 1,000 after a fix tells you that the 100 failing ones now pass, which is necessary, and that the other 900 still pass, which they did before. To bound the rate on stimulus you have not seen, also run fresh seeds.

    Common pitfall

    Runs that are not independent. The arithmetic assumes each run is a fresh chance. Twenty reruns of one seed are one run. Runs on one broken machine, or with a bench change half-way, are not one population. Keep the setting fixed for the whole sweep.

    Common pitfall

    A lower rate is not a fix. A change that takes a bug from 10% to 1% passes most short sweeps and is significant by Fisher's test, but it has only made the bug rarer and harder to find. Ask whether the mechanism is gone, not just whether the rate moved.

    Why this matters

    Senior lens. Write the proof criterion into the bug before the fix is written: which setting, how many runs, what rate it rules out. It stops the argument in review, and it stops a fix being declared done because the first few runs were green.

    For sign-off, report residual risk as a bound per configuration: "0 in 3,000 at the stressed setting, rate below 0.1%". It is honest about what a regression can show.

    Interview

    Asked: "A test failed about once in 500 runs. After your fix it passed 500 times. Is it fixed?"

    Weak answer: Yes. It used to fail once in 500 and now it passed 500 times, so the failure is gone.

    Strong answer: Not proven. If nothing changed, 500 clean runs would still happen about 37% of the time ((499/500)⁵⁰⁰). To rule out the old rate at 95% I need about three times 1/p, roughly 1,500 runs, and even then the statement is that the rate is below about 0.2%. I would rather raise the rate first with a stressed setting where it fails most runs, where a handful of clean runs is decisive, and back that with the mechanism: a test that shows the cause can no longer happen. Then a wide default sweep on fresh seeds bounds anything left.

    Follow-ups: (1) What does 0 failures in 3,000 runs let you claim? (2) How would you show that a change made the rate lower but did not fix it?

    Check yourself A bug fails 1 run in 50. How many clean runs give 95% confidence that a fix changed something, and what do 300 clean runs let you say?

    With p = 2%, n ≥ ln(0.05)/ln(0.98) ≈ 149 runs (about 3/p = 150). After 300 clean runs, the rate is below about 3/300 = 1% at 95% (exactly 0.99%), so the old 2% is ruled out with room to spare, but a much rarer residual bug is not.

    Check yourself Before a change, 12 failures in 400 runs; after, 4 in 400. Your colleague says the change fixed two thirds of the problem. What would you say?

    First, whether the difference is real: the script's last comparison, 12/400 against 4/400, gives p = 0.0739, so a difference this large happens by luck about one time in fourteen. Second, even if real, a bug that still fails 1% of runs is not fixed. Either the change hit only one of two causes, or it narrowed the window of one cause. Sort the remaining failures by signature and look at the mechanism.

    Remember

    • To see a bug of rate p at least once with 95% confidence you need about 3/p runs: 29 for 10%, 299 for 1%, 2,995 for 0.1%.

    • After n clean runs the rate is below about 3/n at 95% (the rule of three). A clean sweep bounds the bug; it never proves zero.

    • Prove a fix on the stressed repro first: at 88% per run, 2 clean runs say as much as 29 at the default 10%.

    Rule of thumb: state the confidence and the rate you are ruling out before you start the sweep, and size the sweep from them.

    How this was checked

    rare.py as shown, Python 3.14.3 (python3 -I -B rare.py), 2026-10-18: the output shown, ending in PASS. Its self-checks compare against values worked by hand: 29 and 44 runs for p = 10%, 2,995 for 0.1%, an upper bound of 0.2991% after 1,000 clean runs, the Clopper-Pearson interval 8.21% to 12.03% for 100 in 1,000.

    Inputs are real measurements from earlier lessons: dbglab B4 100 of 1,000 seeds and 177 of 200 with the zero-fill bias, 21 of 200 at the default and at +OPS=6000 (making a flaky test fail); the FIFO bug 13 of 1,000 nightly seeds and 196 of 200 with bursty stalls (making the rate go up).

    Limits: the arithmetic assumes runs are independent and the rate is the same in every run. Seeds of one bench setting come close; runs that share a machine fault or a changed bench do not. Statistics bound a rate; only the mechanism explains it.

    Keep going

    • Practice: Debugging rare seed failures in regression

    • Practice: Distribution validation harness for constrained random

    • Interview: run a mock interview on this topic

    Related topics

    • Part 8 · Intermittent Failures

    • Next: ordering-dependent bugs

    • Part 9: fix, prove, prevent

    Previous

    75 / 107

    Next: Ordering-Dependent Bugs

    Up next

    Up nextOrdering-Dependent Bugs

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Why Intermittents Happen
    • Making the Rate Go Up
    • Heisenbugs