Artificial Intelligence

Becoming An AI Practitioner

Capstone Project: A Puzzle-Solving Agent

A single Puzzle-Solving Agent application supporting two classic AI puzzles, deliberately chosen because they showcase two different problem-solving paradigms f

JrCodex·9 min read

Jr Codex AI Notes

Level: Advanced Prerequisites: All of Modules 1–6, especially Module 2 and Module 3 Time to complete: ~45 minutes


Table of Contents

  1. What We're Building
  2. Project Structure
  3. Step 1 — A Shared Puzzle Interface
  4. Step 2 — The 8-Puzzle Solver (A* Search)
  5. Step 3 — The Sudoku Solver (CSP)
  6. Step 4 — A Simple Rule-Based Hint Explainer
  7. Step 5 — Tying It Together with a CLI
  8. Step 6 — A Quick Test
  9. Where to Go From Here

1. What We're Building

A single Puzzle-Solving Agent application supporting two classic AI puzzles, deliberately chosen because they showcase two different problem-solving paradigms from this curriculum:

Two Puzzles, Two Paradigms
─────────────────────────────────────────
  8-Puzzle:    solved with A* SEARCH (Module 2, Ch.3) —
                 finding the SHORTEST sequence of moves

  Sudoku:         solved as a CSP (Module 2, Ch.6) —
                    finding ANY valid assignment satisfying
                    all constraints, no "path" involved
─────────────────────────────────────────

This mirrors exactly the distinction Module 2, Chapter 6, Section 7 drew between path-based search and constraint satisfaction — building both side by side reinforces when to reach for each.


2. Project Structure

puzzle_agent/
├── puzzle_base.py       # shared Puzzle interface
├── eight_puzzle.py         # A* solver for the 8-puzzle
├── sudoku_solver.py          # CSP solver for Sudoku
├── explainer.py                 # simple rule-based move explanations
└── main.py                         # CLI tying everything together

3. Step 1 — A Shared Puzzle Interface

Following Module 2, Chapter 1's SearchProblem pattern, and Python Notes' Module 4 OOP principles (an abstract base class, Python Notes Module 4 Ch.6):

# puzzle_base.py
 
from abc import ABC, abstractmethod
 
class Puzzle(ABC):
    """A shared interface both puzzle types implement, for a consistent CLI."""
 
    @abstractmethod
    def is_solved(self) -> bool:
        pass
 
    @abstractmethod
    def solve(self):
        """Returns a solution (format varies by puzzle type)."""
        pass
 
    @abstractmethod
    def display(self) -> str:
        pass

Directly reusing Module 2, Chapter 3's A* implementation and Manhattan-distance heuristic:

# eight_puzzle.py
 
import heapq
from puzzle_base import Puzzle
 
GOAL_STATE = (1, 2, 3, 4, 5, 6, 7, 8, 0)      # 0 represents the blank tile
 
def manhattan_distance(state, goal=GOAL_STATE, size=3):
    """Module 2, Ch.3's admissible heuristic, reused directly."""
    total = 0
    for i, tile in enumerate(state):
        if tile == 0:
            continue
        goal_index = goal.index(tile)
        row1, col1 = divmod(i, size)
        row2, col2 = divmod(goal_index, size)
        total += abs(row1 - row2) + abs(col1 - col2)
    return total
 
def get_neighbors(state, size=3):
    """All states reachable by sliding a tile into the blank space."""
    neighbors = []
    blank_index = state.index(0)
    row, col = divmod(blank_index, size)
 
    moves = {"up": (-1, 0), "down": (1, 0), "left": (0, -1), "right": (0, 1)}
    for move_name, (dr, dc) in moves.items():
        new_row, new_col = row + dr, col + dc
        if 0 <= new_row < size and 0 <= new_col < size:
            new_index = new_row * size + new_col
            new_state = list(state)
            new_state[blank_index], new_state[new_index] = new_state[new_index], new_state[blank_index]
            neighbors.append((move_name, tuple(new_state)))
    return neighbors
 
 
class EightPuzzle(Puzzle):
    def __init__(self, initial_state):
        self.initial_state = tuple(initial_state)
        self.solution_path = None
 
    def is_solved(self):
        return self.initial_state == GOAL_STATE
 
    def solve(self):
        """A* search (Module 2, Ch.3), reused directly against this puzzle."""
        frontier = [(manhattan_distance(self.initial_state), 0, self.initial_state, [])]
        visited = {}
 
        while frontier:
            f, g, state, path = heapq.heappop(frontier)
 
            if state == GOAL_STATE:
                self.solution_path = path
                return path
 
            if state in visited and visited[state] <= g:
                continue
            visited[state] = g
 
            for move_name, next_state in get_neighbors(state):
                new_g = g + 1      # every move costs 1
                new_f = new_g + manhattan_distance(next_state)
                if next_state not in visited or visited[next_state] > new_g:
                    heapq.heappush(frontier, (new_f, new_g, next_state, path + [move_name]))
 
        return None      # unsolvable configuration
 
    def display(self):
        rows = [self.initial_state[i:i+3] for i in range(0, 9, 3)]
        return "\n".join(" ".join(str(t) if t != 0 else "_" for t in row) for row in rows)
 
 
puzzle = EightPuzzle([1, 2, 3, 4, 0, 6, 7, 5, 8])
solution = puzzle.solve()
print(f"Solved in {len(solution)} moves: {solution}")

5. Step 3 — The Sudoku Solver (CSP)

Directly reusing Module 2, Chapter 6's backtracking + forward checking + MRV/LCV recipe:

# sudoku_solver.py
 
from puzzle_base import Puzzle
 
def get_related_cells(cell):
    """All cells sharing a row, column, or 3x3 box with `cell` (row, col)."""
    row, col = cell
    related = set()
    for c in range(9):
        related.add((row, c))
    for r in range(9):
        related.add((r, col))
    box_row, box_col = 3 * (row // 3), 3 * (col // 3)
    for r in range(box_row, box_row + 3):
        for c in range(box_col, box_col + 3):
            related.add((r, c))
    related.discard(cell)
    return related
 
 
class SudokuSolver(Puzzle):
    def __init__(self, grid):
        """grid: 9x9 list of lists, 0 = empty cell."""
        self.grid = [row[:] for row in grid]
        self.variables = [(r, c) for r in range(9) for c in range(9) if grid[r][c] == 0]
        self.domains = {
            (r, c): (set(range(1, 10)) if grid[r][c] == 0 else {grid[r][c]})
            for r in range(9) for c in range(9)
        }
 
    def is_solved(self):
        return all(self.grid[r][c] != 0 for r in range(9) for c in range(9))
 
    def _is_consistent(self, assignment, cell, value):
        for related_cell in get_related_cells(cell):
            if assignment.get(related_cell) == value:
                return False
        return True
 
    def _forward_check(self, assignment, domains, cell, value):
        new_domains = {k: set(v) for k, v in domains.items()}
        for related_cell in get_related_cells(cell):
            if related_cell not in assignment and value in new_domains[related_cell]:
                new_domains[related_cell].discard(value)
                if not new_domains[related_cell]:
                    return None      # dead end
        return new_domains
 
    def _most_constrained_variable(self, assignment, domains):
        unassigned = [v for v in self.variables if v not in assignment]
        return min(unassigned, key=lambda v: len(domains[v]))
 
    def _backtrack(self, assignment, domains):
        if len(assignment) == len(self.variables):
            return assignment
 
        cell = self._most_constrained_variable(assignment, domains)      # MRV heuristic
 
        for value in sorted(domains[cell]):
            if self._is_consistent(assignment, cell, value):
                assignment[cell] = value
                new_domains = self._forward_check(assignment, domains, cell, value)
 
                if new_domains is not None:
                    result = self._backtrack(assignment, new_domains)
                    if result is not None:
                        return result
 
                del assignment[cell]
 
        return None
 
    def solve(self):
        initial_assignment = {
            (r, c): self.grid[r][c] for r in range(9) for c in range(9) if self.grid[r][c] != 0
        }
        result = self._backtrack(initial_assignment, self.domains)
 
        if result:
            for (r, c), value in result.items():
                self.grid[r][c] = value
        return result
 
    def display(self):
        return "\n".join(" ".join(str(v) if v != 0 else "." for v in row) for row in self.grid)
 
 
example_grid = [
    [5,3,0, 0,7,0, 0,0,0], [6,0,0, 1,9,5, 0,0,0], [0,9,8, 0,0,0, 0,6,0],
    [8,0,0, 0,6,0, 0,0,3], [4,0,0, 8,0,3, 0,0,1], [7,0,0, 0,2,0, 0,0,6],
    [0,6,0, 0,0,0, 2,8,0], [0,0,0, 4,1,9, 0,0,5], [0,0,0, 0,8,0, 0,7,9],
]
sudoku = SudokuSolver(example_grid)
sudoku.solve()
print(sudoku.display())

6. Step 4 — A Simple Rule-Based Hint Explainer

A small nod to Module 3's expert systems — a rule-based component explaining why a Sudoku cell must take a specific value, directly connecting Module 3, Chapter 6's transparency point to a real feature:

# explainer.py
 
def explain_naked_single(grid, cell):
    """
    A classic Sudoku human-solving RULE (Module 3, Ch.6's rule-based
    reasoning): if a cell has only ONE possible value remaining after
    eliminating everything already used in its row/column/box, explain why.
    """
    from sudoku_solver import get_related_cells
 
    row, col = cell
    used_values = {grid[r][c] for r, c in get_related_cells(cell) if grid[r][c] != 0}
    possible = set(range(1, 10)) - used_values
 
    if len(possible) == 1:
        value = possible.pop()
        return (f"Cell ({row},{col}) must be {value}: it's the only value "
                f"not already used in its row, column, or 3x3 box.")
    return f"Cell ({row},{col}) has {len(possible)} possible values — not a naked single."

7. Step 5 — Tying It Together with a CLI

# main.py
 
from eight_puzzle import EightPuzzle
from sudoku_solver import SudokuSolver, example_grid
from explainer import explain_naked_single
 
def run_eight_puzzle_demo():
    print("\n=== 8-Puzzle Solver (A* Search) ===")
    puzzle = EightPuzzle([1, 2, 3, 4, 0, 6, 7, 5, 8])
    print("Initial state:")
    print(puzzle.display())
 
    solution = puzzle.solve()
    if solution:
        print(f"\nSolved in {len(solution)} moves: {' -> '.join(solution)}")
    else:
        print("\nNo solution found.")
 
 
def run_sudoku_demo():
    print("\n=== Sudoku Solver (CSP: Backtracking + Forward Checking + MRV) ===")
    sudoku = SudokuSolver(example_grid)
    print("Initial grid:")
    print(sudoku.display())
 
    result = sudoku.solve()
    if result:
        print("\nSolved grid:")
        print(sudoku.display())
        print("\nExample explanation for one originally-empty cell:")
        print(explain_naked_single(example_grid, (0, 2)))
    else:
        print("\nNo solution found.")
 
 
def main():
    run_eight_puzzle_demo()
    run_sudoku_demo()
 
 
if __name__ == "__main__":
    main()
$ python main.py
 
=== 8-Puzzle Solver (A* Search) ===
Initial state:
1 2 3
4 _ 6
7 5 8
 
Solved in 2 moves: down -> right
 
=== Sudoku Solver (CSP: Backtracking + Forward Checking + MRV) ===
Initial grid:
5 3 . . 7 . . . .
...
Solved grid:
5 3 4 6 7 8 9 1 2
...

8. Step 6 — A Quick Test

Following the Python Notes' pytest patterns (Module 5, Chapter 5):

# test_puzzles.py
 
import pytest
from eight_puzzle import EightPuzzle, GOAL_STATE
from sudoku_solver import SudokuSolver
 
def test_eight_puzzle_already_solved():
    puzzle = EightPuzzle(list(GOAL_STATE))
    assert puzzle.is_solved()
 
def test_eight_puzzle_finds_solution():
    puzzle = EightPuzzle([1, 2, 3, 4, 0, 6, 7, 5, 8])
    solution = puzzle.solve()
    assert solution is not None
    assert len(solution) > 0
 
def test_sudoku_solves_valid_puzzle():
    grid = [
        [5,3,0, 0,7,0, 0,0,0], [6,0,0, 1,9,5, 0,0,0], [0,9,8, 0,0,0, 0,6,0],
        [8,0,0, 0,6,0, 0,0,3], [4,0,0, 8,0,3, 0,0,1], [7,0,0, 0,2,0, 0,0,6],
        [0,6,0, 0,0,0, 2,8,0], [0,0,0, 4,1,9, 0,0,5], [0,0,0, 0,8,0, 0,7,9],
    ]
    solver = SudokuSolver(grid)
    result = solver.solve()
    assert result is not None
    assert solver.is_solved()
 
    # Verify every row contains 1-9 with no repeats
    for row in solver.grid:
        assert sorted(row) == list(range(1, 10))
$ pytest test_puzzles.py -v
test_puzzles.py::test_eight_puzzle_already_solved PASSED
test_puzzles.py::test_eight_puzzle_finds_solution PASSED
test_puzzles.py::test_sudoku_solves_valid_puzzle PASSED

9. Where to Go From Here

Module → Technique Used in This Capstone
─────────────────────────────────────────
  Module 1 (Foundations)     → PEAS-style thinking shaped the
                                  Puzzle interface's design
  Module 2, Ch.3 (A*)           → the entire 8-puzzle solver
  Module 2, Ch.6 (CSPs)            → the entire Sudoku solver
                                       (backtracking, forward
                                       checking, MRV)
  Module 3, Ch.6 (Expert Systems)     → the rule-based hint explainer
─────────────────────────────────────────

Extension ideas, using techniques from later in this curriculum:

ExtensionConcepts to Apply
Add a "generate a new Sudoku puzzle" featureConstraint satisfaction, run in reverse
Add difficulty rating based on solving technique neededMore Module 3-style rule-based reasoning
Solve a 15-puzzle (4x4) instead of 8-puzzleSame A* code — just a bigger state space
Add a learning agent that improves solving speed over many puzzlesModule 5's Q-learning, applied to move ordering

You've now completed the entire Artificial Intelligence curriculum's hands-on component. Chapter 3 closes out this curriculum by mapping every module you've completed onto the Machine Learning, Deep Learning, and NLP/LLM notes that follow.


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index