Step zero: make the bug happen on demand
You cannot run experiments on a bug you cannot trigger. So debugging always starts with a reproduction: a snippet that makes the failure happen every time. If you have unit 1's habits, that repro is simply a failing test.
Then minimize it. A bug report says median misbehaves on a big messy list, but big inputs hide causes. Shrink the input while the failure persists: cut the list in half, try each half (the halving idea returns in unit 9), keep whichever still fails, repeat. When you cannot shrink further, the remaining input usually names the bug: if median([1, 2]) fails and median([1, 2, 3]) works, the words "even length" are already in your hypothesis.
A minimization session
median is suspected wrong on an 8-item list, since sorted it reads [1,2,4,4,6,7,8,9] and should give 5.0. The input shrinks while the wrongness survives, down to a 2-item repro.
def median(nums): nums = sorted(nums) return nums[len(nums) // 2] print("big input gives:", median([7, 1, 9, 4, 4, 8, 2, 6])) print("half A gives:", median([7, 1, 9, 4])) print("half B gives:", median([4, 8, 2, 6])) print("minimal repro gives:", median([1, 2]))
Output
big input gives: 6 half A gives: 7 half B gives: 6 minimal repro gives: 2
Every line is wrong, which is the signal that the shrinking may continue. [7, 1, 9, 4] sorts to [1, 4, 7, 9] with a true median of 5.5 and returns 7, and [4, 8, 2, 6] sorts to [2, 4, 6, 8] with a true median of 5 and returns 6.
Halving is a choice, not a rule. Cutting a list in two is convenient, and for a string you might cut it in half by characters, while for a dictionary of settings you would drop half the keys. The invariant is that each step keeps a piece where the failure still happens.
The stopping condition is that you cannot shrink further without the failure disappearing. median([1]) returns 1, which is correct, so 2 elements is the floor here, and that floor is itself a clue.
Reading the minimal repro
median([1, 2]) returns 2, but the median of 1 and 2 is 1.5. With two elements the bug is naked: len(nums) // 2 is 1, and picking index 1 is only correct when the length is odd. For even lengths the median is the average of the two middle elements, indexes n//2 - 1 and n//2.
Notice the loop from lesson 7-1 just happened: observe (6 instead of 5.0), hypothesize (even-length indexing), predict ([1, 2] should fail), experiment (it does), conclude. Now you fix it, and lesson 4-3's rule says the minimal repro becomes a permanent regression test.
Fixing the even-length case
Odd lengths keep the middle element, and even lengths return the average of the two middles. Before the fix, median returned nums[len(nums) // 2] unconditionally.
def median(nums): nums = sorted(nums) n = len(nums) if n % 2 == 1: return nums[n // 2] return (nums[n // 2 - 1] + nums[n // 2]) / 2 def test_odd_length(): assert median([3, 1, 2]) == 2 def test_even_length_minimal_repro(): assert median([1, 2]) == 1.5 def test_even_length_four(): assert median([7, 1, 9, 4]) == 5.5 for test in [test_odd_length, test_even_length_minimal_repro, test_even_length_four]: test() print("PASS", test.__name__)
Output
PASS test_odd_length PASS test_even_length_minimal_repro PASS test_even_length_four
The branch is on parity, so n % 2 == 1 keeps the old behavior for odd lengths and the even case needs new code. The two middle elements of a sorted even-length list sit at indexes n // 2 - 1 and n // 2, and averaging them with / 2 produces a float, which is why 1.5 is expressible at all.
The middle test is named for what it is, namely the minimal repro promoted to a permanent test. That is lesson 4-3's rule in action, and the name is worth keeping literal so a future reader knows this input was not chosen at random.
test_even_length_four guards a case the minimal repro cannot. With two elements, n // 2 - 1 is 0, so a mistaken nums[0] would still pass, and with four elements the indexes are 1 and 2, giving 4 and 7 for a result of 5.5. Two even-length tests at different sizes is the cheapest way to rule that out.
Minimizing a string input
Minimization works on any input, not just lists. count_words reports 5 words for a 4-word sentence, and the same shrinking process reduces it to a 4-character repro.
def count_words(text): return len(text.split(" ")) print("full sentence gives:", count_words("debugging is detective work")) print("first half gives:", count_words("debugging is ")) print("second half gives:", count_words("is detective")) print("minimal repro gives:", count_words("a b"))
Output
full sentence gives: 5 first half gives: 3 second half gives: 3 minimal repro gives: 3
"a b" is two words and counts as 3, because the double space yields an empty string in the middle, giving ['a', '', 'b']. The words double space are the hypothesis, so the minimal repro named the bug, which is the payoff the technique promises.
Note that both halves also over-count, and for different reasons. "debugging is " has a trailing space that produces an empty final element, while "is detective" carries the real double space. Two distinct causes producing the same symptom is common, and it is why you keep shrinking rather than stopping at the first failing piece.
This is the same trap as lesson 3-3's initials bug and it takes the same fix, since split() with no argument treats any run of whitespace as one separator and drops empty pieces. Meeting one bug from three directions, as a test case, a crash, and a minimization target, is how the whitespace rule stops being something you have to look up.