FreeCampus Python

Debug One Hypothesis at a Time

Reproduce wrong behavior, localize its first divergence, test one falsifiable hypothesis, repair the cause, and verify against regressions.
python-foundations errors-exceptions-debugging debugging-method logic-bugs
Open in Colab
  • Level: Python Foundations
  • Estimated time: 4–5 hours
  • You will learn: Turn a wrong result into a stable reproduction, localize its first divergence, test falsifiable hypotheses, and verify a root-cause repair with regression evidence.
  • Practice in: Google Colab, JupyterLab, or a local editor

A weekend tournament awards points for wins and a streak bonus for consecutive wins. The following function produces a plausible total, raises no exception, and is wrong:

def tournament_score(results):
    """Return 3 points per win plus 2 for each win after the first in a streak."""
    total = 0
    streak = 0

    for result in results:
        if result == "W":
            streak += 1
            total += 3 + (2 * streak)
        else:
            streak = 0

    return total


print(tournament_score(["W", "W", "L", "W"]))

The correct total is 11: three wins contribute nine points, and only the second consecutive win contributes a two-point streak bonus. The function returns 17. This lesson is about the work between “wrong” and “fixed.”

Keep one rule throughout: each experiment should distinguish between possible causes. Editing several lines and seeing a pass tells you less than changing one cause and predicting exactly what new evidence should appear.

1. Freeze the failure before touching the code

A reproducible bug has a known starting state, action, expected result, and actual result. Turn the report into an assertion:

failing_results = ["W", "W", "L", "W"]
expected_score = 11
actual_score = tournament_score(failing_results)

print("input:", failing_results)
print("expected:", expected_score)
print("actual:", actual_score)
assert actual_score == expected_score

Do not repair yet. Record:

Reproduction: tournament_score(["W", "W", "L", "W"])
Expected: 11, calculated from 3 + (3 + 2) + 0 + 3
Actual: 17
Environment: clean notebook run, current saved function
Frequency: every clean run

If a report cannot be reproduced, that is evidence—not permission to guess. Check the exact input representation, random seed, call order, Python version, saved file, working directory, configuration, and notebook state. Change one environmental factor at a time.

2. Establish a control case and nearby boundaries

A control is a simple case expected to work. It shows which parts of the program are not yet implicated:

assert tournament_score([]) == 0
assert tournament_score(["L"]) == 0

Now try the smallest win case:

print(tournament_score(["W"]))

The contract says one win earns 3, but the function returns 5. That smaller failure removes loss/reset behavior from suspicion. The defect is already present on the first win.

Build a behavior table before editing:

Input Expected Actual before repair Rule isolated
[] 0 0 empty tournament
['L'] 0 0 a loss scores nothing
['W'] 3 5 first win has no streak bonus
['W', 'W'] 8 12 second win adds one bonus
['W', 'L', 'W'] 6 10 loss resets the streak

The one-win case is a minimal reproduction: removing its only event makes the failure disappear. Smaller input reduces the number of states you must explain.

TipA smaller failure is a sharper question

Minimization is not only for bug reports. It is an investigation tool. Remove one item, branch, function call, or configuration detail and ask whether the same rule still fails.

3. Separate facts from hypotheses

Facts come directly from observations or the contract:

  • tournament_score(["W"]) returns 5 on a clean run;
  • the rule awards 3 for a first win;
  • no loss branch runs for that input;
  • streak begins at zero.

Hypotheses are proposed explanations:

  • H1: streak is not initialized to zero.
  • H2: the bonus formula applies to the first win instead of only later wins.
  • H3: the loss branch incorrectly adds points.

H1 and H3 conflict with visible evidence: the source initializes zero, and the minimal case never enters the loss branch. H2 predicts exactly the observed two extra points.

A useful hypothesis names a mechanism and a predicted observation:

If the formula uses the new streak length directly, then the first win will add 2 * 1; printing the base and bonus separately will show base 3, bonus 2 before total becomes 5.

“The loop is weird” cannot be meaningfully disproved. “The first win receives a bonus because the calculation uses streak rather than streak - 1” can.

Checkpoint: make the failure testable

4. Localize the first wrong stage

For larger programs, identify the first stage where actual behavior differs from expected behavior. Model the scorer as a pipeline:

raw results -> iterate one result -> update streak -> calculate bonus -> update total

The input is already a clean list, iteration sees the correct first item, and the streak becomes one as expected. The first divergence is bonus calculation. Do not edit parsing, iteration, or reset code when their observed states still match the contract.

Debugging moves from a broad failing outcome toward the first state transition that violates a known rule.

flowchart LR
  report["Wrong final total"] --> pipeline["Name program stages"]
  pipeline --> boundary["Compare expected and actual after each stage"]
  boundary --> first["Find first divergence"]
  first --> hypothesis["Predict a local cause"]
  hypothesis --> experiment["Observe or change one thing"]
  experiment --> verify["Rerun original and nearby cases"]

Later wrong values can be consequences. Repair the earliest supported cause, not every downstream symptom.

Function boundaries provide natural checkpoints. Suppose a leaderboard has three stages:

def normalize_results(results):
    return [result.strip().upper() for result in results]


def score_results(results):
    return tournament_score(results)


def build_summary(raw_results):
    normalized = normalize_results(raw_results)
    score = score_results(normalized)
    return {"results": normalized, "score": score}

Check outputs at each boundary:

raw = [" w ", "W", "l", "W"]
normalized = normalize_results(raw)
print("normalized:", normalized)
assert normalized == ["W", "W", "L", "W"]

score = score_results(normalized)
print("score:", score)
assert score == 11

If normalization passes and scoring fails, the first broken boundary lies after normalization. That does not prove every normalizer case is correct; it localizes this reproduction.

5. Trace state when the problem lives inside a loop

A state trace records selected values after each meaningful transition. Create an instrumented version without changing the scoring decisions:

def trace_tournament_score(results):
    total = 0
    streak = 0
    trace = []

    for turn, result in enumerate(results, start=1):
        before_total = total
        if result == "W":
            streak += 1
            base = 3
            bonus = 2 * streak
            total += base + bonus
        else:
            streak = 0
            base = 0
            bonus = 0

        trace.append(
            {
                "turn": turn,
                "result": result,
                "streak": streak,
                "base": base,
                "bonus": bonus,
                "before": before_total,
                "after": total,
            }
        )

    return total, trace


actual, trace = trace_tournament_score(["W"])
print(actual)
print(trace[0])

The trace should show base: 3, bonus: 2, after: 5. That observation supports H2. It also falsifies an alternative claim that addition happens twice in separate statements.

Choose only variables needed for the hypothesis. Good instrumentation might show a loop index, current item, branch chosen, before value, delta, and after value. Printing entire nested objects on every iteration can bury the transition you need.

Remove or convert temporary prints after the repair. Preserve the valuable rule as an assertion so future runs check it automatically.

6. Change one cause and predict the result

The contract says a streak bonus applies only to wins after the first. If streak is one, bonus count should be zero; if streak is two, bonus count should be one. Repair only that formula:

def tournament_score(results):
    """Return 3 points per win plus 2 for each win after the first in a streak."""
    total = 0
    streak = 0

    for result in results:
        if result == "W":
            streak += 1
            bonus_wins = streak - 1
            total += 3 + (2 * bonus_wins)
        else:
            streak = 0

    return total

Before running, predict:

  • ['W'] becomes 3;
  • ['W', 'W'] becomes 8;
  • the original case becomes 11;
  • loss-only controls remain zero.

Then execute the exact checks:

assert tournament_score([]) == 0
assert tournament_score(["L"]) == 0
assert tournament_score(["W"]) == 3
assert tournament_score(["W", "W"]) == 8
assert tournament_score(["W", "L", "W"]) == 6
assert tournament_score(["W", "W", "L", "W"]) == 11

A matching prediction is evidence that the causal model improved. If the original case passes but a control regresses, the change is not complete.

Checkpoint: locate before you repair

7. Repair the cause, not the visible symptom

A symptom patch could subtract six from the original result:

def patch_one_example(results):
    return tournament_score(results) - 6

It might match one reported case while breaking an empty tournament and every other shape. Another weak patch might special-case the exact failing list. Both encode examples rather than the streak rule.

A root-cause repair changes the earliest wrong decision supported by evidence. It should explain why all nearby cases now work:

  • first win: streak - 1 is zero;
  • second consecutive win: it is one;
  • a loss resets streak to zero;
  • a later isolated win again receives no bonus.

After repairing, ask what other behavior shares the changed line. Test long streaks, repeated losses, empty input, and alternating results. A fix can solve the report while exposing a neighboring boundary.

8. Keep regression evidence and clean the investigation

A regression check is a small executable example that would fail if the defect returned. The strongest one is often the minimal reproduction:

def test_first_win_has_no_streak_bonus():
    assert tournament_score(["W"]) == 3


test_first_win_has_no_streak_bonus()

Unit 13 will organize these functions with pytest. Right now, naming the rule and running the assertion is enough to preserve learning.

Complete the repair with a clean verification:

  1. remove unrelated experimental changes;
  2. keep a concise assertion for the original defect;
  3. run the minimal case, original case, controls, and nearby boundaries;
  4. restart the notebook runtime and run top to bottom, or rerun the saved script;
  5. record the cause and why the repair matches the contract.

Do not describe a repair only as “changed line 8.” Line numbers move. Record the behavioral reason: “The formula treated the first win as an additional streak win; subtracting one from the streak count makes bonuses begin at win two.”

9. Intermittent failures need controlled repetition

Suppose a game selects a random prize and a test sometimes fails. Repeatedly clicking Run creates observations but not a stable experiment. Control the random input or inject it:

def prize_message(prize):
    if prize == "coin":
        return "You found a coin"
    if prize == "map":
        return "You found a map"
    return "Mystery prize"


assert prize_message("coin") == "You found a coin"
assert prize_message("map") == "You found a map"

For time-dependent, network, or environment-dependent behavior, record the varying dependency and substitute a controlled value when the design permits. Unit 13 covers test doubles and dependency injection more fully.

10. Use AI suggestions as hypotheses, not evidence

An assistant can propose suspicious lines, smaller examples, or possible assertions. It cannot observe your exact runtime unless you supply accurate, safe evidence, and a plausible explanation can still be wrong.

Use this workflow:

  1. remove secrets and unrelated private data;
  2. provide the minimal complete reproduction, exact traceback or output, expected behavior, environment, and attempts already made;
  3. ask for several falsifiable hypotheses and an experiment for each;
  4. inspect every suggested change before running it;
  5. run one controlled experiment locally;
  6. keep the repair only when original and regression cases support it.

Never paste tokens, passwords, private student records, or proprietary data into an external tool. Replace values while preserving the shape that triggers the failure.

11. Investigate a second scoreboard defect

The tournament now supports a three-point comeback bonus after a loss followed by two wins. This implementation silently awards it too soon:

def comeback_score(results):
    total = 0
    previous = None
    wins_after_loss = 0

    for result in results:
        if result == "W":
            total += 3
            if previous == "L":
                wins_after_loss += 1
            if wins_after_loss == 1:
                total += 3
        else:
            wins_after_loss = 0
        previous = result

    return total

The rule says ['L', 'W'] earns 3, while ['L', 'W', 'W'] earns 9. Conduct the investigation rather than jumping to a patch:

  1. freeze both assertions and record actual results;
  2. find a passing control;
  3. shrink any longer reported failure;
  4. list facts separately from at least three hypotheses;
  5. trace result, previous, wins_after_loss, and total per turn;
  6. mark the first divergence;
  7. run one experiment that falsifies a hypothesis;
  8. make one root-cause repair;
  9. verify empty, loss-only, isolated-win, qualifying-comeback, and repeated-win cases from a clean state.

Use this evidence table:

Stage Expected Actual Evidence Next hypothesis
reproduction
smallest case
first divergence
controlled experiment
clean verification
Compare a root-cause repair after completing the investigation

One clear model remembers whether a loss has armed a comeback and counts wins until the two-win requirement is satisfied:

def comeback_score(results):
    total = 0
    comeback_armed = False
    wins_after_loss = 0

    for result in results:
        if result == "L":
            comeback_armed = True
            wins_after_loss = 0
            continue

        total += 3
        if comeback_armed:
            wins_after_loss += 1
            if wins_after_loss == 2:
                total += 3
                comeback_armed = False
                wins_after_loss = 0

    return total


assert comeback_score([]) == 0
assert comeback_score(["W"]) == 3
assert comeback_score(["L", "W"]) == 3
assert comeback_score(["L", "W", "W"]) == 9
assert comeback_score(["L", "W", "W", "W"]) == 12

This is one contract-consistent design, not the only syntax that can work. Your record should still show which observation falsified a hypothesis and why the state stops awarding the comeback after it is earned.

Checkpoint: close the investigation responsibly

12. Key points and your reusable debugging record

For future lessons and projects, keep this compact template:

Failure statement:
Environment and clean-run steps:
Smallest reproduction:
Expected result and oracle:
Actual result or exception:
Passing control:
Facts:
Candidate hypotheses:
First divergence:
Experiment and prediction:
Observed result:
Root-cause repair:
Regression cases:
Clean verification:

The record is not bureaucracy. It prevents repeated guesses, supports a teammate who joins later, and makes your own reasoning inspectable.

References and next steps

Next, you will pause a running program, inspect stack frames and local variables, step across function calls, and reduce a realistic report into a minimal reproduction someone else can run.

Back to top