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?
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.
Assume each run fails independently with the same probability p, which is close to true for seeds of one bench setting.
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.
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.
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.
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.
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.
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.
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.
# 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")$ 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 outTwenty 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.
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.
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.
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.
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.