Skip to main content

Python Assertions and Invariants Practice Problems & Exercises

Practice: Assertions and Invariants

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Basic Assert SyntaxEasy
assert statementAssertionErrorerror messages

Write a function compute_average that asserts the input list is not empty (with the message "Cannot average an empty list"), then returns the average of all values.

This tests the most basic assert syntax: assert condition, message.

Python
def compute_average(values):
    assert len(values) > 0, "Cannot average an empty list"
    return sum(values) / len(values)


# Test
print(compute_average([10, 20, 30]))
try:
    compute_average([])
except AssertionError as e:
    print(f"AssertionError: {e}")
Solution
def compute_average(values):
assert len(values) > 0, "Cannot average an empty list"
return sum(values) / len(values)

Key points:

  • assert condition, message raises AssertionError with the given message when condition is falsy.
  • An empty list is falsy, so assert values would also work, but len(values) > 0 is more explicit.
  • This is appropriate for an internal invariant. For user-facing validation, use if not values: raise ValueError(...).
def compute_average(values):
  # TODO: Add an assert that 'values' is not empty,
  # with the message "Cannot average an empty list"
  # Then return the average.
  pass


# Test
print(compute_average([10, 20, 30]))
try:
  compute_average([])
except AssertionError as e:
  print(f"AssertionError: {e}")
Expected Output
20.0
AssertionError: Cannot average an empty list
Hints

Hint 1: Use: assert condition, "message" — the message is optional but required here.

Hint 2: Check len(values) > 0 or just 'values' (truthy for non-empty lists).

#2Assert vs Raise — Fix the MisuseEasy
assert vs raiseinput validationValueError

The function withdraw incorrectly uses assert for input validation. Rewrite all four checks using if/raise with appropriate exception types: TypeError for type checks, ValueError for value checks.

This is the most important lesson about assertions: never use them for input validation because they vanish under python -O.

Python
def withdraw(balance, amount):
    if not isinstance(balance, (int, float)):
        raise TypeError(f"balance must be numeric, got {type(balance).__name__}")
    if not isinstance(amount, (int, float)):
        raise TypeError(f"amount must be numeric, got {type(amount).__name__}")
    if amount <= 0:
        raise ValueError("amount must be positive")
    if amount > balance:
        raise ValueError("insufficient funds")
    return balance - amount


# Test
print(withdraw(100, 30))
try:
    withdraw(100, -5)
except ValueError as e:
    print(f"ValueError: {e}")
try:
    withdraw(100, 200)
except ValueError as e:
    print(f"ValueError: {e}")
try:
    withdraw("hundred", 10)
except TypeError as e:
    print(f"TypeError: {e}")
Solution
def withdraw(balance, amount):
if not isinstance(balance, (int, float)):
raise TypeError(f"balance must be numeric, got {type(balance).__name__}")
if not isinstance(amount, (int, float)):
raise TypeError(f"amount must be numeric, got {type(amount).__name__}")
if amount <= 0:
raise ValueError("amount must be positive")
if amount > balance:
raise ValueError("insufficient funds")
return balance - amount

Key points:

  • assert is disabled under python -O. Input validation must survive in production — use if/raise.
  • TypeError is the correct exception for wrong types. ValueError is correct for wrong values.
  • Callers can now catch specific exception types (except ValueError) instead of the generic AssertionError.
# BAD: This function uses assert for input validation.
# Rewrite it using if/raise with proper exception types.

def withdraw(balance, amount):
  assert isinstance(balance, (int, float)), "balance must be numeric"
  assert isinstance(amount, (int, float)), "amount must be numeric"
  assert amount > 0, "amount must be positive"
  assert amount <= balance, "insufficient funds"
  return balance - amount


# Test (should work the same after your fix)
print(withdraw(100, 30))
try:
  withdraw(100, -5)
except ValueError as e:
  print(f"ValueError: {e}")
try:
  withdraw(100, 200)
except ValueError as e:
  print(f"ValueError: {e}")
try:
  withdraw("hundred", 10)
except TypeError as e:
  print(f"TypeError: {e}")
Expected Output
70
ValueError: amount must be positive
ValueError: insufficient funds
TypeError: balance must be numeric, got str
Hints

Hint 1: Replace each assert with an if/raise — use TypeError for type checks and ValueError for value checks.

Hint 2: Include the actual type name in TypeError messages using type(x).__name__.

#3What Does __debug__ Do?Easy
-O flag__debug__assert removal

Answer four questions about Python's __debug__ constant and the -O flag by assigning the correct values to the answer variables.

Understanding how -O strips assertions is critical to using them correctly.

Python
answer_1 = True   # __debug__ is True in normal mode
answer_2 = "b"    # Bytecode is removed entirely under -O
answer_3 = False  # __debug__ is a constant — cannot be reassigned
answer_4 = False  # The condition is NOT evaluated under -O

print(f"Q1: {answer_1}")
print(f"Q2: {answer_2}")
print(f"Q3: {answer_3}")
print(f"Q4: {answer_4}")
Solution
answer_1 = True
answer_2 = "b"
answer_3 = False
answer_4 = False

Key points:

  • __debug__ is True by default. Running python -O script.py sets it to False.
  • Under -O, assert condition, message compiles to nothing — the bytecode is removed entirely. The condition expression is never evaluated, so any side effects in the condition will not execute.
  • __debug__ is a special built-in constant. Attempting __debug__ = False raises a SyntaxError.
# Predict the output of each print statement,
# then fill in the expected values.

# Question: What is __debug__ in normal mode?
answer_1 = None  # TODO: True or False?

# Question: What does 'assert x > 0' compile to when __debug__ is False?
# (a) if not (x > 0): raise AssertionError
# (b) nothing — the bytecode is removed entirely
# (c) if not (x > 0): pass
answer_2 = None  # TODO: "a", "b", or "c"?

# Question: Can you assign a new value to __debug__?
answer_3 = None  # TODO: True or False?

# Question: Does the condition expression get evaluated under -O?
answer_4 = None  # TODO: True or False?

print(f"Q1: {answer_1}")
print(f"Q2: {answer_2}")
print(f"Q3: {answer_3}")
print(f"Q4: {answer_4}")
Expected Output
Q1: True
Q2: b
Q3: False
Q4: False
Hints

Hint 1: __debug__ is True by default and False under python -O.

Hint 2: Under -O, the entire assert statement is removed at the bytecode level — not even the condition runs.

Hint 3: __debug__ is a built-in constant — you cannot reassign it.

#4Classify Assert vs RaiseEasy
assert vs raisedecision frameworkbest practices

For each of the six scenarios, decide whether you should use assert or if/raise. Set each variable to "assert" or "raise".

The decision framework: assert is for internal invariants you control. if/raise is for external input validation that must run in production.

Python
scenario_1 = "raise"   # User input — must validate in production
scenario_2 = "assert"  # Internal invariant — documents code correctness
scenario_3 = "raise"   # External API data — must always validate
scenario_4 = "assert"  # Unreachable branch — programmer error if reached
scenario_5 = "raise"   # User input — security check must survive -O
scenario_6 = "assert"  # Loop invariant — development correctness check

print(f"1. User email check: {scenario_1}")
print(f"2. Internal sorted check: {scenario_2}")
print(f"3. API body check: {scenario_3}")
print(f"4. Unreachable branch: {scenario_4}")
print(f"5. Password length: {scenario_5}")
print(f"6. Loop invariant: {scenario_6}")
Solution
scenario_1 = "raise"
scenario_2 = "assert"
scenario_3 = "raise"
scenario_4 = "assert"
scenario_5 = "raise"
scenario_6 = "assert"

Decision framework:

  • Use if/raise when the condition could be false due to external input (users, APIs, config files, network data). These checks must survive python -O.
  • Use assert when the condition documents an internal guarantee — something that should never be false if your code is correct. These are development aids that can safely disappear in production.
  • Security checks (like password validation) always use if/raise — never trust assert for security.
# For each scenario, decide: should you use 'assert' or 'if/raise'?
# Set each answer to "assert" or "raise".

# 1. Checking that a user-submitted email contains '@'
scenario_1 = None

# 2. Verifying an internal helper always returns a sorted list
scenario_2 = None

# 3. Ensuring an API request body has a required 'user_id' field
scenario_3 = None

# 4. Marking a branch in a match/case that should be unreachable
scenario_4 = None

# 5. Checking that a password meets minimum length in a signup form
scenario_5 = None

# 6. Verifying a loop invariant in a binary search implementation
scenario_6 = None

print(f"1. User email check: {scenario_1}")
print(f"2. Internal sorted check: {scenario_2}")
print(f"3. API body check: {scenario_3}")
print(f"4. Unreachable branch: {scenario_4}")
print(f"5. Password length: {scenario_5}")
print(f"6. Loop invariant: {scenario_6}")
Expected Output
1. User email check: raise
2. Internal sorted check: assert
3. API body check: raise
4. Unreachable branch: assert
5. Password length: raise
6. Loop invariant: assert
Hints

Hint 1: If the data comes from a user, API, or external source — use if/raise.

Hint 2: If the condition documents an internal guarantee about YOUR code — use assert.

Hint 3: Ask: must this check survive in a production deployment with python -O?


Medium

#5Preconditions and PostconditionsMedium
design by contractpreconditionspostconditions

Implement clamp(value, low, high) using Design by Contract: add an assert for the pre-condition, implement the logic, then add asserts for all three post-conditions documented in the docstring.

This practices the DbC pattern: pre-conditions at entry, post-conditions before return.

Python
def clamp(value, low, high):
    """Clamp value to the range [low, high]."""
    # Pre-condition
    assert low <= high, f"Pre-condition: low ({low}) must be <= high ({high})"

    result = max(low, min(value, high))

    # Post-conditions
    assert result >= low, f"Post-condition: result ({result}) < low ({low})"
    assert result <= high, f"Post-condition: result ({result}) > high ({high})"
    assert (low <= value <= high) == (result == value), (
        f"Post-condition: in-range value should be unchanged"
    )

    return result


# Test
print(clamp(5, 0, 10))
print(clamp(-3, 0, 10))
print(clamp(15, 0, 10))
print(clamp(0, 0, 10))
print(clamp(10, 0, 10))
try:
    clamp(5, 10, 0)
except AssertionError as e:
    print(f"AssertionError: {e}")
Solution
def clamp(value, low, high):
assert low <= high, f"Pre-condition: low ({low}) must be <= high ({high})"

result = max(low, min(value, high))

assert result >= low, f"Post-condition: result ({result}) < low ({low})"
assert result <= high, f"Post-condition: result ({result}) > high ({high})"
assert (low <= value <= high) == (result == value), (
f"Post-condition: in-range value should be unchanged"
)

return result

Key points:

  • Pre-conditions document what callers must guarantee. Here, low <= high is a contract.
  • Post-conditions verify the function's own guarantees. If a refactor breaks the logic, these fire immediately.
  • The third post-condition checks that the function does not modify values already in range — a subtle but important correctness property.
def clamp(value, low, high):
  """Clamp value to the range [low, high].

  Pre-conditions:
      - low <= high

  Post-conditions:
      - result >= low
      - result <= high
      - if low <= value <= high, result == value
  """
  # TODO: Add assert for the pre-condition (low <= high)
  # TODO: Implement the clamping logic
  # TODO: Add asserts for all three post-conditions
  # TODO: Return the result
  pass


# Test
print(clamp(5, 0, 10))
print(clamp(-3, 0, 10))
print(clamp(15, 0, 10))
print(clamp(0, 0, 10))
print(clamp(10, 0, 10))
try:
  clamp(5, 10, 0)
except AssertionError as e:
  print(f"AssertionError: {e}")
Expected Output
5
0
10
0
10
AssertionError: Pre-condition: low (10) must be <= high (0)
Hints

Hint 1: The pre-condition assert checks low <= high before any logic runs.

Hint 2: Clamping: if value < low return low, if value > high return high, else return value. Or use max(low, min(value, high)).

Hint 3: Post-conditions verify the result is within bounds and equals value when value was already in range.

#6Class Invariant CheckerMedium
class invariants_check_invariantOOP

Implement a Fraction class with a class invariant: the denominator is always positive and the fraction is always in lowest terms. Use if/raise for the zero-denominator check (user input) and assert in _check_invariant for the internal structural invariants.

Python
import math

class Fraction:
    def __init__(self, numerator, denominator):
        if denominator == 0:
            raise ValueError("denominator must not be zero")

        # Normalize sign: denominator always positive
        if denominator < 0:
            numerator = -numerator
            denominator = -denominator

        # Reduce to lowest terms
        g = math.gcd(abs(numerator), denominator)
        self._num = numerator // g
        self._den = denominator // g

        self._check_invariant()

    def _check_invariant(self):
        assert self._den > 0, f"Invariant: denominator must be positive, got {self._den}"
        assert math.gcd(abs(self._num), self._den) == 1, (
            f"Invariant: fraction not in lowest terms: {self._num}/{self._den}"
        )

    def multiply(self, other):
        return Fraction(self._num * other._num, self._den * other._den)

    def __repr__(self):
        return f"Fraction({self._num}, {self._den})"


# Test
f1 = Fraction(2, 4)
print(f1)
f2 = Fraction(-3, -6)
print(f2)
f3 = f1.multiply(f2)
print(f3)
try:
    Fraction(1, 0)
except ValueError as e:
    print(f"ValueError: {e}")
Solution
import math

class Fraction:
def __init__(self, numerator, denominator):
if denominator == 0:
raise ValueError("denominator must not be zero")
if denominator < 0:
numerator, denominator = -numerator, -denominator
g = math.gcd(abs(numerator), denominator)
self._num = numerator // g
self._den = denominator // g
self._check_invariant()

def _check_invariant(self):
assert self._den > 0, f"Invariant: denominator must be positive, got {self._den}"
assert math.gcd(abs(self._num), self._den) == 1, (
f"Invariant: fraction not in lowest terms: {self._num}/{self._den}"
)

def multiply(self, other):
return Fraction(self._num * other._num, self._den * other._den)

Key points:

  • if/raise ValueError for zero denominator — this is user-facing validation that must survive -O.
  • assert in _check_invariant for structural invariants — these catch internal logic bugs during development.
  • multiply returns a new Fraction, so the constructor re-normalizes and re-checks the invariant automatically.
  • Fraction(-3, -6) becomes Fraction(1, 2) because both signs flip and then GCD reduces.
class Fraction:
  """A simplified fraction that maintains the invariant:
  denominator is always positive and the fraction is always
  in lowest terms (GCD of numerator and denominator is 1).
  """

  def __init__(self, numerator, denominator):
      # TODO: Validate denominator != 0 using if/raise (user input)
      # TODO: Normalize so denominator is always positive
      # TODO: Reduce to lowest terms using math.gcd
      # TODO: Store self._num and self._den
      # TODO: Call self._check_invariant()
      pass

  def _check_invariant(self):
      # TODO: Assert denominator > 0
      # TODO: Assert GCD(abs(numerator), denominator) == 1
      pass

  def multiply(self, other):
      """Return a new Fraction = self * other."""
      # TODO: Compute new numerator and denominator
      # TODO: Return new Fraction (invariant checked in __init__)
      pass

  def __repr__(self):
      return f"Fraction({self._num}, {self._den})"


import math

# Test
f1 = Fraction(2, 4)
print(f1)
f2 = Fraction(-3, -6)
print(f2)
f3 = f1.multiply(f2)
print(f3)
try:
  Fraction(1, 0)
except ValueError as e:
  print(f"ValueError: {e}")
Expected Output
Fraction(1, 2)
Fraction(1, 2)
Fraction(1, 4)
Hints

Hint 1: Use math.gcd to reduce the fraction. Remember to handle the sign: if denominator is negative, flip both signs.

Hint 2: The invariant check asserts den > 0 and math.gcd(abs(num), den) == 1.

Hint 3: multiply creates a new Fraction — the constructor handles normalization and invariant checking.

#7Loop Invariant in Linear SearchMedium
loop invariantslinear searchcorrectness

Add three assertions to the linear_search function: (1) the loop invariant at the start of each iteration, (2) a post-condition when the target is found, and (3) a post-condition when the target is not found.

Loop invariants document what the algorithm maintains at each step — they are the formal basis for proving correctness.

Python
def linear_search(arr, target):
    for i in range(len(arr)):
        # Loop invariant: target is not in any element we already checked
        assert target not in arr[:i], (
            f"Invariant violated: target {target} found in arr[:{i}] = {arr[:i]}"
        )

        if arr[i] == target:
            assert arr[i] == target
            return i

    # Post-condition: exhausted all elements without finding target
    assert target not in arr, (
        f"Search ended but {target} IS in {arr}"
    )
    return -1


# Test
data = [4, 7, 2, 9, 1, 5]
print(linear_search(data, 9))
print(linear_search(data, 5))
print(linear_search(data, 3))
Solution
def linear_search(arr, target):
for i in range(len(arr)):
assert target not in arr[:i], (
f"Invariant violated: target {target} found in arr[:{i}]"
)
if arr[i] == target:
assert arr[i] == target
return i

assert target not in arr, f"Search ended but {target} IS in {arr}"
return -1

Key points:

  • The loop invariant target not in arr[:i] states that none of the already-checked elements match the target.
  • At iteration i=0, arr[:0] is empty, so the invariant holds trivially.
  • After each iteration where arr[i] != target, the invariant still holds for i+1.
  • When the loop exits without returning, the invariant covers the entire array: target not in arr[:len(arr)].
  • Note: arr[:i] creates a slice each iteration — this is O(n) per check, making the total O(n**2). That is fine for development but would be removed in production via -O.
def linear_search(arr, target):
  """Return the index of target in arr, or -1 if not found.

  Loop invariant: target is NOT in arr[0:i] at the start of
  each iteration (all elements before index i have been checked
  and none of them equal target).
  """
  for i in range(len(arr)):
      # TODO: Add an assert for the loop invariant:
      # target is not in arr[0:i]

      if arr[i] == target:
          # TODO: Add a post-condition assert: arr[i] == target
          return i

  # TODO: Add a post-condition assert: target not in arr
  return -1


# Test
data = [4, 7, 2, 9, 1, 5]
print(linear_search(data, 9))
print(linear_search(data, 5))
print(linear_search(data, 3))
Expected Output
3
5
-1
Hints

Hint 1: The loop invariant: assert target not in arr[:i] — at the start of iteration i, we have already checked indices 0 through i-1.

Hint 2: The found post-condition: assert arr[i] == target.

Hint 3: The not-found post-condition: assert target not in arr.

#8Type Narrowing with AssertMedium
type narrowingisinstanceOptionalUnion types

Use assert for type narrowing in two functions: (1) narrow Optional[str] to str using assert value is not None, and (2) narrow a Union[str, list, dict] to dict in the else branch using assert isinstance.

Type narrowing assertions help both runtime debugging and static type checkers like mypy.

Python
from typing import Optional, Union

def get_length(value: Optional[str]) -> int:
    assert value is not None, "get_length requires a non-None value"
    return len(value)


def process_input(data: Union[str, list, dict]) -> str:
    if isinstance(data, str):
        return data.upper()
    elif isinstance(data, list):
        return f"list with {len(data)} items"
    else:
        assert isinstance(data, dict), f"Expected dict, got {type(data).__name__}"
        return f"dict with {len(data)} keys"


# Test
print(get_length("hello"))
print(process_input("hello"))
print(process_input([1, 2, 3]))
print(process_input({"a": 1, "b": 2}))
try:
    get_length(None)
except AssertionError as e:
    print(f"AssertionError: {e}")
Solution
def get_length(value: Optional[str]) -> int:
assert value is not None, "get_length requires a non-None value"
return len(value)


def process_input(data: Union[str, list, dict]) -> str:
if isinstance(data, str):
return data.upper()
elif isinstance(data, list):
return f"list with {len(data)} items"
else:
assert isinstance(data, dict), f"Expected dict, got {type(data).__name__}"
return f"dict with {len(data)} keys"

Key points:

  • assert value is not None narrows Optional[str] to str. After this line, mypy and Pyright know value is a str.
  • assert isinstance(data, dict) narrows the remaining Union type to dict in the else branch.
  • Both patterns serve dual purposes: runtime debugging AND static type analysis.
  • For production code, replace with if value is None: raise ValueError(...) to keep the narrowing while surviving -O.
from typing import Optional, Union

def get_length(value: Optional[str]) -> int:
  """Return the length of value.

  Pre-condition: value is not None.
  """
  # TODO: Add an assert that narrows the type from Optional[str] to str
  # TODO: Return the length
  pass


def process_input(data: Union[str, list, dict]) -> str:
  """Convert data to a summary string.

  - str -> return it uppercase
  - list -> return "list with N items"
  - dict -> return "dict with N keys"
  """
  if isinstance(data, str):
      return data.upper()
  elif isinstance(data, list):
      return f"list with {len(data)} items"
  else:
      # TODO: Add an assert isinstance narrowing 'data' to dict
      # TODO: Return the dict summary
      pass


# Test
print(get_length("hello"))
print(process_input("hello"))
print(process_input([1, 2, 3]))
print(process_input({"a": 1, "b": 2}))
try:
  get_length(None)
except AssertionError as e:
  print(f"AssertionError: {e}")
Expected Output
5
HELLO
list with 3 items
dict with 2 keys
AssertionError: get_length requires a non-None value
Hints

Hint 1: Use 'assert value is not None' to narrow Optional[str] to str.

Hint 2: Use 'assert isinstance(data, dict)' in the else branch to narrow Union to dict.

Hint 3: Type checkers (mypy, Pyright) recognize assert isinstance as a type guard.


Hard

#9State Machine with Transition InvariantsHard
class invariantsstate machinetransitions

Build a Pipeline state machine class that uses assert for class invariants (in _check_invariant) and if/raise for user-facing validation (in transition). The class must maintain three invariants documented in the docstring.

This combines class invariants with the assert-vs-raise decision framework in a realistic scenario.

Python
VALID_TRANSITIONS = {
    "idle": {"running", "error"},
    "running": {"paused", "completed", "error"},
    "paused": {"running", "cancelled"},
    "completed": set(),
    "cancelled": set(),
    "error": {"idle"},
}

ALL_STATES = set(VALID_TRANSITIONS.keys())


class Pipeline:
    def __init__(self):
        self._state = "idle"
        self._history = ["idle"]
        self._check_invariant()

    def _check_invariant(self):
        assert self._state in ALL_STATES, (
            f"Invariant: invalid state '{self._state}'"
        )
        assert len(self._history) > 0, "Invariant: history must be non-empty"
        assert self._history[-1] == self._state, (
            f"Invariant: history tail '{self._history[-1]}' != state '{self._state}'"
        )

    def transition(self, new_state):
        self._check_invariant()

        if new_state not in ALL_STATES:
            raise ValueError(f"Unknown state: '{new_state}'")
        if new_state not in VALID_TRANSITIONS[self._state]:
            raise ValueError(
                f"Invalid transition: '{self._state}' -> '{new_state}'"
            )

        self._state = new_state
        self._history.append(new_state)
        self._check_invariant()

    @property
    def state(self):
        return self._state

    @property
    def history(self):
        return list(self._history)


# Test
p = Pipeline()
print(f"State: {p.state}")
p.transition("running")
print(f"State: {p.state}")
p.transition("paused")
p.transition("running")
p.transition("completed")
print(f"History: {p.history}")
try:
    p.transition("running")
except ValueError as e:
    print(f"ValueError: {e}")
try:
    p.transition("exploded")
except ValueError as e:
    print(f"ValueError: {e}")
Solution
class Pipeline:
def __init__(self):
self._state = "idle"
self._history = ["idle"]
self._check_invariant()

def _check_invariant(self):
assert self._state in ALL_STATES
assert len(self._history) > 0
assert self._history[-1] == self._state

def transition(self, new_state):
self._check_invariant()
if new_state not in ALL_STATES:
raise ValueError(f"Unknown state: '{new_state}'")
if new_state not in VALID_TRANSITIONS[self._state]:
raise ValueError(f"Invalid transition: '{self._state}' -> '{new_state}'")
self._state = new_state
self._history.append(new_state)
self._check_invariant()

Key points:

  • _check_invariant uses assert because these are internal structural guarantees — if they fail, there is a bug in the class implementation.
  • transition uses if/raise ValueError because invalid state names and transitions are user errors that must be caught even under -O.
  • Calling _check_invariant at the start and end of transition ensures the invariant is maintained across every mutation.
  • The history property returns a copy (list(self._history)) to prevent external code from breaking the invariant by modifying the list.
VALID_TRANSITIONS = {
  "idle": {"running", "error"},
  "running": {"paused", "completed", "error"},
  "paused": {"running", "cancelled"},
  "completed": set(),
  "cancelled": set(),
  "error": {"idle"},
}

ALL_STATES = set(VALID_TRANSITIONS.keys())


class Pipeline:
  """A data pipeline with enforced state transitions.

  Class invariants:
      1. self._state is always in ALL_STATES
      2. self._history is a non-empty list
      3. self._history[-1] == self._state
  """

  def __init__(self):
      self._state = "idle"
      self._history = ["idle"]
      # TODO: call _check_invariant

  def _check_invariant(self):
      # TODO: Assert invariant 1: state is valid
      # TODO: Assert invariant 2: history is non-empty
      # TODO: Assert invariant 3: history[-1] matches state
      pass

  def transition(self, new_state):
      """Transition to new_state.

      Uses if/raise for invalid transitions (user action).
      Uses assert for internal invariants.
      """
      # TODO: call _check_invariant (pre)
      # TODO: if/raise ValueError for invalid state names
      # TODO: if/raise ValueError for invalid transitions
      # TODO: Update state and history
      # TODO: call _check_invariant (post)
      pass

  @property
  def state(self):
      return self._state

  @property
  def history(self):
      return list(self._history)


# Test
p = Pipeline()
print(f"State: {p.state}")
p.transition("running")
print(f"State: {p.state}")
p.transition("paused")
p.transition("running")
p.transition("completed")
print(f"History: {p.history}")
try:
  p.transition("running")
except ValueError as e:
  print(f"ValueError: {e}")
try:
  p.transition("exploded")
except ValueError as e:
  print(f"ValueError: {e}")
Expected Output
State: idle
State: running
History: ['idle', 'running', 'paused', 'running', 'completed']
ValueError: Invalid transition: 'completed' -> 'running'
ValueError: Unknown state: 'exploded'
Hints

Hint 1: _check_invariant uses assert for all three conditions — these are internal structural guarantees.

Hint 2: transition uses if/raise ValueError for invalid state names and invalid transitions — these are user-facing errors.

Hint 3: Call _check_invariant at the start and end of transition to verify invariants hold across mutations.

#10Assertion-Verified Insertion SortHard
loop invariantssortingpostconditions

Add loop invariant and post-condition assertions to the insertion sort algorithm. At each iteration, assert that the prefix arr[0:i] is sorted and that no elements have been lost or gained. After the loop, assert the entire array is sorted with the same elements.

Python
def insertion_sort(arr):
    arr = arr.copy()
    original_sorted = sorted(arr)
    n = len(arr)

    for i in range(1, n):
        # Loop invariant: prefix is sorted
        assert arr[:i] == sorted(arr[:i]), (
            f"Invariant broken at i={i}: arr[:{i}] = {arr[:i]} is not sorted"
        )
        # Element preservation: no elements lost or gained
        assert sorted(arr) == original_sorted, (
            f"Elements changed at i={i}: {sorted(arr)} != {original_sorted}"
        )

        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = key

    # Post-conditions
    assert arr == sorted(arr), f"Post-condition: result is not sorted: {arr}"
    assert sorted(arr) == original_sorted, (
        f"Post-condition: elements changed: {sorted(arr)} != {original_sorted}"
    )
    return arr


# Test
print(insertion_sort([5, 3, 8, 1, 2]))
print(insertion_sort([1]))
print(insertion_sort([]))
print(insertion_sort([4, 4, 2, 2, 1, 1]))
Solution
def insertion_sort(arr):
arr = arr.copy()
original_sorted = sorted(arr)
n = len(arr)

for i in range(1, n):
assert arr[:i] == sorted(arr[:i]), f"Invariant broken at i={i}"
assert sorted(arr) == original_sorted, f"Elements changed at i={i}"

key = arr[i]
j = i - 1
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key

assert arr == sorted(arr), "Post-condition: not sorted"
assert sorted(arr) == original_sorted, "Post-condition: elements changed"
return arr

Key points:

  • The loop invariant arr[:i] == sorted(arr[:i]) captures the core correctness property of insertion sort: at iteration i, everything before index i is already sorted.
  • Element preservation (sorted(arr) == original_sorted) ensures the algorithm does not lose, duplicate, or corrupt elements. This catches subtle index bugs.
  • These assertions add O(n log n) per iteration (due to sorted()), making the total O(n**2 log n) — acceptable for development, removed by -O in production.
  • Empty and single-element lists skip the loop entirely. The post-conditions still hold trivially.
def insertion_sort(arr):
  """Sort a list using insertion sort with invariant assertions.

  Loop invariant: arr[0:i] is sorted before each outer iteration.
  Post-condition: the entire array is sorted and contains the
  same elements as the original input.
  """
  arr = arr.copy()
  original_sorted = sorted(arr)
  n = len(arr)

  for i in range(1, n):
      # TODO: Assert loop invariant — arr[0:i] is sorted
      # TODO: Assert element preservation — sorted(arr) == original_sorted

      key = arr[i]
      j = i - 1
      while j >= 0 and arr[j] > key:
          arr[j + 1] = arr[j]
          j -= 1
      arr[j + 1] = key

  # TODO: Assert post-condition — entire array is sorted
  # TODO: Assert post-condition — same elements as original
  return arr


# Test
print(insertion_sort([5, 3, 8, 1, 2]))
print(insertion_sort([1]))
print(insertion_sort([]))
print(insertion_sort([4, 4, 2, 2, 1, 1]))
Expected Output
[1, 2, 3, 5, 8]
[1]
[]
[1, 1, 2, 2, 4, 4]
Hints

Hint 1: Loop invariant: assert arr[:i] == sorted(arr[:i])

Hint 2: Element preservation: assert sorted(arr) == original_sorted — no elements lost or gained.

Hint 3: Post-conditions check the same two properties for the entire array.

#11ML Data Pipeline AssertionsHard
ML pipelineshape invariantsdata validation

Build a data validation and splitting function for an ML pipeline. Add assertions for all pre-conditions and post-conditions documented in the docstring. This is a realistic pattern used in ML training pipelines where shape mismatches and data corruption cause silent failures.

Python
def validate_and_split(features, labels, test_ratio=0.2):
    # Pre-conditions
    assert isinstance(features, list) and len(features) > 0, (
        "features must be a non-empty list"
    )
    assert all(isinstance(row, list) for row in features), (
        "features must be 2D (list of lists)"
    )
    feature_width = len(features[0])
    assert all(len(row) == feature_width for row in features), (
        f"All feature rows must have length {feature_width}"
    )
    assert isinstance(labels, list), "labels must be a list"
    assert len(features) == len(labels), (
        f"Feature/label count mismatch: {len(features)} features, {len(labels)} labels"
    )
    assert 0 < test_ratio < 1, (
        f"test_ratio must be between 0 and 1, got {test_ratio}"
    )

    n = len(features)
    split_idx = int(n * (1 - test_ratio))

    assert 0 < split_idx < n, (
        f"Split index {split_idx} invalid for {n} samples — "
        f"adjust test_ratio ({test_ratio})"
    )

    train_X = features[:split_idx]
    train_y = labels[:split_idx]
    test_X = features[split_idx:]
    test_y = labels[split_idx:]

    # Post-conditions
    assert len(train_X) + len(test_X) == n, (
        f"Sample count mismatch: {len(train_X)} + {len(test_X)} != {n}"
    )
    assert sorted(train_y + test_y) == sorted(labels), (
        "Label loss detected after split"
    )
    assert len(train_X) > 0, "Train set is empty"
    assert len(test_X) > 0, "Test set is empty"
    assert all(len(row) == feature_width for row in train_X + test_X), (
        "Feature width inconsistent after split"
    )

    return train_X, train_y, test_X, test_y


# Test
features = [
    [1.0, 2.0, 3.0],
    [4.0, 5.0, 6.0],
    [7.0, 8.0, 9.0],
    [10.0, 11.0, 12.0],
    [13.0, 14.0, 15.0],
]
labels = [0, 1, 0, 1, 0]

train_X, train_y, test_X, test_y = validate_and_split(features, labels)
print(f"Train: {len(train_X)} samples, Test: {len(test_X)} samples")
print(f"Train features: {train_X}")
print(f"Test labels: {test_y}")

try:
    validate_and_split([[1], [2]], [0, 1, 2])
except AssertionError as e:
    print(f"AssertionError: {e}")
Solution
def validate_and_split(features, labels, test_ratio=0.2):
# Pre-conditions
assert isinstance(features, list) and len(features) > 0
assert all(isinstance(row, list) for row in features)
feature_width = len(features[0])
assert all(len(row) == feature_width for row in features)
assert isinstance(labels, list)
assert len(features) == len(labels), (
f"Feature/label count mismatch: {len(features)} features, {len(labels)} labels"
)
assert 0 < test_ratio < 1

n = len(features)
split_idx = int(n * (1 - test_ratio))
assert 0 < split_idx < n

train_X = features[:split_idx]
train_y = labels[:split_idx]
test_X = features[split_idx:]
test_y = labels[split_idx:]

# Post-conditions
assert len(train_X) + len(test_X) == n
assert sorted(train_y + test_y) == sorted(labels)
assert len(train_X) > 0 and len(test_X) > 0
assert all(len(row) == feature_width for row in train_X + test_X)

return train_X, train_y, test_X, test_y

Key points:

  • Pre-conditions validate data shape (2D features, 1D labels, matching counts) and parameter bounds — these catch the most common ML data bugs.
  • The split index assertion catches edge cases where test_ratio is too close to 0 or 1 for the dataset size.
  • Post-conditions verify no data loss (sorted comparison for labels) and consistent feature width after splitting.
  • In a real ML pipeline with NumPy, you would check X.ndim == 2, y.ndim == 1, np.isnan, and np.isinf — the same pattern applies.
  • These are internal development assertions. In a production scoring service, replace with if/raise for any checks that must survive -O.
def validate_and_split(features, labels, test_ratio=0.2):
  """Validate ML data and split into train/test sets.

  Pre-conditions:
      - features is a 2D list (list of lists, each same length)
      - labels is a 1D list
      - len(features) == len(labels)
      - test_ratio is between 0 and 1 (exclusive)

  Post-conditions:
      - train + test lengths equal original length
      - no samples lost or gained
      - train and test sets are non-empty
  """
  # TODO: Assert all four pre-conditions with descriptive messages

  n = len(features)
  split_idx = int(n * (1 - test_ratio))

  # TODO: Assert split_idx is valid (> 0 and < n)

  train_X = features[:split_idx]
  train_y = labels[:split_idx]
  test_X = features[split_idx:]
  test_y = labels[split_idx:]

  # TODO: Assert post-condition: lengths add up
  # TODO: Assert post-condition: no label loss
  # TODO: Assert post-condition: both sets non-empty
  # TODO: Assert post-condition: feature width consistent

  return train_X, train_y, test_X, test_y


# Test
features = [
  [1.0, 2.0, 3.0],
  [4.0, 5.0, 6.0],
  [7.0, 8.0, 9.0],
  [10.0, 11.0, 12.0],
  [13.0, 14.0, 15.0],
]
labels = [0, 1, 0, 1, 0]

train_X, train_y, test_X, test_y = validate_and_split(features, labels)
print(f"Train: {len(train_X)} samples, Test: {len(test_X)} samples")
print(f"Train features: {train_X}")
print(f"Test labels: {test_y}")

try:
  validate_and_split([[1], [2]], [0, 1, 2])
except AssertionError as e:
  print(f"AssertionError: {e}")
Expected Output
Train: 4 samples, Test: 1 samples
Train features: [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], [10.0, 11.0, 12.0]]
Test labels: [0]
AssertionError: Feature/label count mismatch: 2 features, 3 labels
Hints

Hint 1: Check features is 2D: all(isinstance(row, list) for row in features) and that all rows have the same length.

Hint 2: Check labels is a flat list: all(not isinstance(label, list) for label in labels).

Hint 3: Post-conditions: len(train_X) + len(test_X) == n, and sorted(train_y + test_y) == sorted(labels).

© 2026 EngineersOfAI. All rights reserved.