AI EngineeringZero to ProductionHome·About·Contact
Appendix · Python for AI Agents · Part 10

Computational thinking & correctness

The final Python-Fundamentals part is about writing code you can reason about and trust. You will learn to state and prove a loop invariant, encode pre/postconditions as assertions, and think in properties that must hold for all inputs rather than a handful of examples. Along the way you will implement the iterator protocol two ways (a class and a generator), read a table of the real cost of the operations you reach for daily (why list.pop(0) is O(n) but deque.popleft() is O(1); why x in set beats x in list), confront the __eq__/__hash__ contract and the concrete bug when they disagree, and finally the floating-point pitfalls that make 0.1 + 0.2 != 0.3. Correctness first, then speed.

⏱️ ~2.5 hours🎯 Advanced → Industry✅ correctness & costrunnable CPython 3.13

Learning objectives

  • State a loop invariant and use it (with the exit condition) to argue an algorithm is correct.
  • Encode pre/postconditions as assertions, and know that -O strips them.
  • Implement the iterator protocol both as a class and as a generator, and explain iterable vs iterator.
  • Read the real cost (Big-O) of list/deque/dict/set operations and pick the right structure.
  • Uphold the __eq__/__hash__ contract and recognize the bug when it's broken.
  • Avoid floating-point pitfalls (equality, money, rounding) and think in properties, not examples.

1 · Loop invariants

A loop invariant is a statement that is true just before the loop starts and remains true after every iteration. Combined with the loop's exit condition, an invariant lets you prove the loop produces the right answer — the same reasoning graders and interviewers ask for. The trick is to write the invariant as a checkable assertion so a wrong one fails loudly.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it
invariant.py# A loop INVARIANT is a condition true before the loop and preserved by every
# iteration. Together with the exit condition it proves the result.
def find_max(xs):
    assert len(xs) > 0, "precondition: xs is non-empty"
    best = xs[0]
    for i in range(1, len(xs)):
        # INVARIANT (holds at the top of each iteration):
        #   best == max(xs[:i])
        assert best == max(xs[:i])          # checkable statement of the invariant
        if xs[i] > best:
            best = xs[i]
    # At exit i == len(xs), so the invariant gives: best == max(xs[:len(xs)])
    assert best == max(xs), "postcondition"
    return best

print(find_max([3, 1, 4, 1, 5, 9, 2, 6]))   # 9
9

The invariant here is best == max(xs[:i]): true before the loop (best = xs[0], i = 1), and preserved by the body (each step extends the considered prefix by one and keeps the running max). At exit i == len(xs), so the invariant becomes exactly the postcondition best == max(xs) — a proof, not a hope.

2 · Pre/postconditions & assertions

A precondition is what a function requires of its inputs; a postcondition is what it guarantees about its output. assert both documents and checks them, turning silent wrong behavior into a loud failure at the exact spot. One caveat that trips people in production: assertions are removed when Python runs with -O, so never use them for input validation that must always run — use explicit checks/exceptions there, and use assertions for internal invariants.

Try it
contracts.py# Preconditions guard the inputs; postconditions guarantee the output. Assertions
# document AND check both. (Note: assertions are stripped under `python -O`.)
import math

def newton_sqrt(x, iters=50):
    assert x >= 0, "precondition: x must be non-negative"
    if x == 0:
        return 0.0
    guess = x
    for _ in range(iters):
        guess = (guess + x / guess) / 2      # Newton-Raphson step
    assert math.isclose(guess * guess, x, rel_tol=1e-9), "postcondition failed"
    return guess

print(round(newton_sqrt(2), 6))              # 1.414214
try:
    newton_sqrt(-1)
except AssertionError as e:
    print("caught:", e)
1.414214
caught: precondition: x must be non-negative
assert is not input validationBecause python -O strips assert, guarding untrusted input with assert user_id > 0 silently vanishes in an optimized deployment. Use if not ...: raise ValueError(...) for validation; reserve assert for invariants you believe are always true.

3 · The iterator & iterable protocol

Iteration is a protocol, not magic. An iterable defines __iter__, which returns an iterator; an iterator defines __next__ (return the next item, raise StopIteration when done) and an __iter__ that returns itself. A for loop is sugar for “call iter(), then next() until StopIteration.” A generator is the easy way to get one: yield makes the compiler write the whole protocol for you, including the lazy, resumable state.

Try it
iterator.py# The protocol: an ITERABLE has __iter__ (returns an iterator); an ITERATOR has
# __next__ (returns items, raises StopIteration to stop) and __iter__ (returns self).

class Countdown:                      # a hand-written iterator
    def __init__(self, start): self.n = start
    def __iter__(self): return self   # an iterator is its own iterable
    def __next__(self):
        if self.n <= 0:
            raise StopIteration       # signals "no more items"
        self.n -= 1
        return self.n + 1

print("class iterator:", list(Countdown(3)))    # [3, 2, 1]

def countdown(start):                 # a GENERATOR: the compiler writes the
    while start > 0:                   # __iter__/__next__/StopIteration for you
        yield start
        start -= 1

print("generator     :", list(countdown(3)))    # [3, 2, 1]

# for-loops speak this protocol: iter(obj) then repeated next() until StopIteration
it = iter([10, 20])
print(next(it), next(it))             # 10 20
try:
    next(it)
except StopIteration:
    print("exhausted")
class iterator: [3, 2, 1]
generator     : [3, 2, 1]
10 20
exhausted
for x in obj loop iter(obj) get iterator next(it) get item yield / return produce value StopIteration stop

4 · The real cost of your Python

Two lines that look equally innocent can differ by orders of magnitude at scale. Choosing the right built-in data structure is usually a bigger win than any micro-optimization. The table gives the amortized Big-O of common operations; the practical headline is: list.pop(0)/insert(0, x) are O(n) (everything shifts), collections.deque gives O(1) at both ends, and membership testing is O(n) in a list but O(1) in a set/dict.

Try it
cost.pyfrom collections import deque
import time

def bench(fn, n=1000):
    t = time.perf_counter()
    for _ in range(n):
        fn()
    return (time.perf_counter() - t) * 1000   # ms per 1000 calls

L = list(range(10_000))
S = set(range(10_000))
print("x in list :", round(bench(lambda: 9999 in L), 2), "ms/1000")   # slow, O(n)
print("x in set  :", round(bench(lambda: 9999 in S), 2), "ms/1000")   # fast, O(1)

# list vs deque at the front:
lst = list(range(100_000))
dq = deque(range(100_000))
print("list.pop(0)     is O(n): shifts every element")
print("deque.popleft() is O(1):", dq.popleft())                       # 0
x in list : 61.11 ms/1000
x in set  : 0.05 ms/1000
list.pop(0)     is O(n): shifts every element
deque.popleft() is O(1): 0
Timings are illustrative; the Big-O is exactThe millisecond figures depend on your machine, but the ratio (a list membership test hundreds of times slower than a set's) is the reproducible, structural truth. Optimize by picking the right container first.
Operationlistdequedict / set
Index / key accessO(1)O(1) ends, O(n) middleO(1) by key
Append at endO(1) amortizedO(1)O(1) insert
Insert / pop at frontO(n)O(1)
Membership (x in s)O(n)O(n)O(1) average
Search by valueO(n)O(n)O(1) by key

5 · The __eq__ / __hash__ contract

Hash-based containers (set, dict) rely on a contract: if two objects are equal, they must have the same hash. Break it and you get real bugs. Define __eq__ without __hash__ and Python makes your type unhashable (can't be a set member or dict key). Provide an inconsistent hash and equal objects land in different buckets, so a set keeps duplicates. The fix is always the same: hash exactly the fields you compare.

Try it
eq_hash.py# The contract: if a == b then hash(a) == hash(b). Break it and dicts/sets break.

# BUG 1: define __eq__ but forget __hash__ -> Python makes the type UNHASHABLE.
class BadPoint:
    def __init__(self, x, y): self.x, self.y = x, y
    def __eq__(self, o): return (self.x, self.y) == (o.x, o.y)
try:
    {BadPoint(1, 2)}                  # can't put it in a set
except TypeError as e:
    print("unhashable:", type(e).__name__)

# BUG 2: equal objects with DIFFERENT hashes -> duplicates leak into a set.
class SneakyPoint:
    _n = 0
    def __init__(self, x, y): self.x, self.y = x, y
    def __eq__(self, o): return (self.x, self.y) == (o.x, o.y)
    def __hash__(self):
        SneakyPoint._n += 1
        return SneakyPoint._n         # inconsistent hash: violates the contract
s = {SneakyPoint(1, 2), SneakyPoint(1, 2)}
print("leaked duplicates:", len(s))  # 2  -> equal points, but both stored!

# CORRECT: hash the SAME fields you compare.
class Point:
    def __init__(self, x, y): self.x, self.y = x, y
    def __eq__(self, o): return isinstance(o, Point) and (self.x, self.y) == (o.x, o.y)
    def __hash__(self): return hash((self.x, self.y))
print("correct dedup   :", len({Point(1, 2), Point(1, 2)}))   # 1
unhashable: TypeError
leaked duplicates: 2
correct dedup   : 1

6 · Floating-point pitfalls

Floats are IEEE-754 binary fractions, so decimals like 0.1 and 0.2 have no exact representation — their sum is a hair off 0.3, and == says so. Never compare floats with ==; use math.isclose. For money and other exact-decimal needs use decimal.Decimal. And note Python's round uses banker's rounding (round half to even), so round(2.5) == 2.

Try it
floats.py# Floats are binary IEEE-754: most decimals (0.1, 0.2) can't be represented exactly.
import math
from decimal import Decimal

print("0.1 + 0.2      :", 0.1 + 0.2)                 # 0.30000000000000004
print("== 0.3         :", 0.1 + 0.2 == 0.3)          # False
print("math.isclose   :", math.isclose(0.1 + 0.2, 0.3))          # True
print("Decimal money  :", Decimal("0.1") + Decimal("0.2"))       # 0.3 (exact)

# Rounding is not always "round half up" — Python uses banker's rounding.
print("round(2.5)     :", round(2.5))                # 2  (round half to even)
print("round(3.5)     :", round(3.5))                # 4
0.1 + 0.2      : 0.30000000000000004
== 0.3         : False
math.isclose   : True
Decimal money  : 0.3
round(2.5)     : 2
round(3.5)     : 4
This is not a Python bug — it's IEEE-754Every language using binary floating point (C, Java, JavaScript) prints 0.1 + 0.2 = 0.30000000000000004. The lesson is universal: use tolerance-based comparison for floats and a decimal type for exact base-10 arithmetic.

7 · Property-based thinking

Example-based tests check a few hand-picked inputs; property-based thinking asks what must hold for every input and checks it against many generated cases. A correct sort, for instance, must output an ordered sequence that is a permutation of the input with the same length — three properties that catch far more bugs than a couple of literal examples. (Libraries like hypothesis automate the generation; here we do it by hand to show the idea.)

Try it
properties.py# Property-based thinking: assert what must hold for ALL inputs, not examples.
import random

def my_sort(xs):
    return sorted(xs)

def is_permutation(a, b):
    return sorted(a) == sorted(b)          # same multiset of elements

# Properties of a correct sort, checked on many random inputs:
for _ in range(1000):
    xs = [random.randint(-50, 50) for _ in range(random.randint(0, 20))]
    out = my_sort(xs)
    assert all(out[i] <= out[i + 1] for i in range(len(out) - 1)), "ordered"
    assert is_permutation(xs, out), "same elements"
    assert len(out) == len(xs), "same length"

print("all sort properties held over 1000 random inputs")
all sort properties held over 1000 random inputs

✓ Checkpoint — you can move on when you can…

  • State a loop invariant for a running-sum or running-max loop and show it yields the postcondition.
  • Explain why assert must not be used for input validation in production.
  • Write both a class-based iterator and an equivalent generator, and explain iterable vs iterator.
  • Pick between list, deque, and set for a front-insert workload and a membership workload, with Big-O reasons.
  • Fix a class that has __eq__ but is unhashable, and explain why 0.1 + 0.2 != 0.3.
✓ Knowledge check

A service validates access with assert user.is_admin, 'forbidden' and it works in tests, but in production some non-admins get through. What happened?

Show answer
The deployment runs Python with the -O flag (or PYTHONOPTIMIZE), which strips all assert statements. The authorization check simply isn't there in the optimized bytecode, so everyone passes. Assertions are for internal invariants you believe are always true, not for security or input validation. Fix: if not user.is_admin: raise PermissionError('forbidden'). Never guard untrusted input or access control with assert.
✓ Knowledge check

You store custom Money objects in a set to dedupe, but equal amounts aren't being deduplicated — the set keeps both. You did define __eq__. Diagnose and fix.

Show answer
You almost certainly gave the objects an inconsistent (or default identity-based) hash while overriding equality, violating the contract “equal objects must hash equal.” With different hashes, two equal Money objects land in different buckets, so the set never compares them and keeps both. Fix: define __hash__ over the same fields used in __eq__ — e.g. def __hash__(self): return hash(self.cents). (If instead you defined __eq__ and no __hash__, the type becomes unhashable and the set insert raises TypeError — the same contract, different symptom.) Also consider making value objects immutable so their hash can't drift.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Assert a loop invariantBeginner

Context: Making an invariant explicit as an assertion turns a fragile loop into a self-checking one.

Your task: Write a running-sum loop and assert its invariant on each iteration.

Requirements:

  • Sum a list with an explicit accumulator loop
  • Assert total == sum(xs[:i+1]) after processing index i
  • Return the total and print it

💡 Hint: The invariant relates the accumulator to the prefix processed so far.

Show solution
Solution
def running_sum(xs):
    total = 0
    for i, x in enumerate(xs):
        total += x
        assert total == sum(xs[:i + 1])   # invariant
    return total

print(running_sum([2, 4, 6]))   # 12
12
Exercise 2 · Write an iterator two waysIntermediate

Context: Understanding the protocol lets you build lazy data sources; generators make it painless.

Your task: Implement a range-like sequence as a class-based iterator and as a generator, and show they produce the same list.

Requirements:

  • A class with __iter__ and __next__ raising StopIteration
  • A generator using yield
  • Show both give the same result

💡 Hint: An iterator's __iter__ returns self.

Show solution
Solution
class Upto:
    def __init__(self, n): self.i, self.n = 0, n
    def __iter__(self): return self
    def __next__(self):
        if self.i >= self.n: raise StopIteration
        self.i += 1
        return self.i - 1

def upto(n):
    i = 0
    while i < n:
        yield i
        i += 1

print(list(Upto(4)))   # [0, 1, 2, 3]
print(list(upto(4)))   # [0, 1, 2, 3]
[0, 1, 2, 3]
[0, 1, 2, 3]
Exercise 3 · Pick the right structure by costAdvanced

Context: A queue built on list.pop(0) degrades to O(n) per dequeue and tanks throughput at scale; the fix is a one-line structure change.

Your task: Show that a deque-based queue is asymptotically better than a list-based one at the front, and explain the Big-O.

Requirements:

  • Dequeue from the front of a large list vs a deque
  • State the Big-O of each front operation
  • Recommend the correct structure

💡 Hint: Removing from the front of a list shifts every remaining element.

Show solution
Solution
from collections import deque

lst = list(range(1_000_000))
dq = deque(range(1_000_000))

# list.pop(0): O(n) -- shifts ~1M elements each call
# deque.popleft(): O(1)
print(lst.pop(0))     # 0  (but O(n): avoid in a loop)
print(dq.popleft())   # 0  (O(1): correct for a queue)
0
0

A FIFO queue must use deque (O(1) at both ends). Using a list and pop(0) makes each dequeue O(n), so draining n items is O(n²).

Exercise 4 · Make a value object a correct dict keyExpert

Context: Value objects used as cache keys or set members must satisfy the eq/hash contract or the cache silently misbehaves.

Your task: Write an immutable-ish Coord that compares by value and can be a dict key, and prove equal coords collapse to one entry.

Requirements:

  • __eq__ comparing the fields
  • __hash__ over the SAME fields
  • Use two equal coords as dict keys and show the dict has one entry

💡 Hint: Hash a tuple of the compared fields.

Show solution
Solution
class Coord:
    def __init__(self, x, y): self.x, self.y = x, y
    def __eq__(self, o): return isinstance(o, Coord) and (self.x, self.y) == (o.x, o.y)
    def __hash__(self): return hash((self.x, self.y))

seen = {}
seen[Coord(1, 2)] = 'a'
seen[Coord(1, 2)] = 'b'          # same key -> overwrites
print(len(seen), list(seen.values()))   # 1 ['b']
1 ['b']
Prefer @dataclass(frozen=True)A @dataclass(frozen=True) auto-generates a consistent __eq__ and __hash__ and makes the object immutable — the safest way to get a correct value object without hand-writing the contract.
Exercise 5 · Property-test a function under refactorProfessional

Context: You are about to replace a hand-rolled dedup-and-sort with a faster implementation and must not change behavior. Example tests won't catch subtle regressions.

Your task: Write property checks that any correct 'sorted unique' implementation must satisfy, and run them on random inputs against your implementation.

Requirements:

  • Properties: output is sorted, has no duplicates, and is a subset of the input covering all its distinct values
  • Generate many random inputs and assert all properties
  • Report success

💡 Hint: Compare against sorted(set(xs)) as the specification.

Show solution
Solution
import random

def sorted_unique(xs):
    return sorted(set(xs))          # the implementation under test

for _ in range(2000):
    xs = [random.randint(0, 9) for _ in range(random.randint(0, 15))]
    out = sorted_unique(xs)
    assert out == sorted(out), 'sorted'
    assert len(out) == len(set(out)), 'unique'
    assert set(out) == set(xs), 'covers distinct values'
print('all properties held')
all properties held
In real projects, use hypothesisThe hypothesis library generates inputs, shrinks failing cases to a minimal example, and integrates with pytest — the industrial version of what this loop does by hand.
Exercise 6 · Root-cause a correctness incidentIndustry scenario

Context: A billing service intermittently double-charges customers and, separately, a dedup set of transactions occasionally lets duplicates through. Postmortem time: the code uses float for money, guards a critical check with assert, and stores custom Txn objects (with a mutable amount) in a set.

Your task: Identify the three latent correctness bugs from this lesson and give the fix for each.

Requirements:

  • Explain the money/float bug and the fix
  • Explain why the assert guard can vanish and the fix
  • Explain the eq/hash + mutability bug in the dedup set and the fix

💡 Hint: Three separate principles: float equality, -O stripping asserts, and the eq/hash contract with mutable keys.

Show solution

Bug 1 — float money. Summing/compare of float amounts accumulates representation error (0.1 + 0.2 != 0.3), so totals and equality checks drift, causing off-by-a-cent and double-charge edge cases. Fix: use decimal.Decimal (or integer cents) for money, never binary floats.

Bug 2 — assert as a guard. The critical check written as assert not already_charged is silently removed when the service runs under python -O, so the guard disappears in production. Fix: if already_charged: raise BillingError(...); reserve assert for internal invariants.

Bug 3 — eq/hash + mutability. Txn objects define equality but either hash inconsistently or are mutated after insertion, so their hash changes and the set can no longer find the existing entry — duplicates leak in. Fix: make Txn immutable (@dataclass(frozen=True)) and hash the same fields you compare; never mutate an object while it lives in a set/dict key.

The unifying lessonCorrectness bugs cluster around three fault lines this lesson drew: floating-point equality, assertions that don't always run, and the eq/hash contract (especially with mutable keys). Design them out up front — Decimal for money, explicit exceptions for guards, frozen value objects for keys.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in