Foundations to FrontierPart VIII: ProjectsBezhig

J02: Project 2: grid pathfinding with a correctness oracle

1 exercise, each with a complete solution. 1 figure. 2 code listings.

On this page
  1. Prerequisites and observable goals
  2. Purpose and exact problem
  3. Architecture and method choice
  4. Milestones and acceptance criteria
  5. Complete solutions: J02
  6. Complete reference implementation
  7. Run and validate
  8. Failure analysis and design tradeoffs
  9. Evaluation rubric and unassigned extensions
  10. Retention and readiness

Prerequisites and observable goals#

Before

C06 queues and heaps; C07 invariants; C08 graph search; C14 testing and measurement.

After

Implement A* for a precisely bounded graph model, prove its heuristic conditions, compare with BFS, and benchmark without sacrificing correctness.

Level: Second integrated project; directly relevant to games but explainable as a general graph problem.

Purpose and exact problem#

Find a shortest path through a rectangular grid with four-neighbor moves of cost one. Coordinates are integer pairs (x,y)(x,y), with 0≤x<width0\le x<\text{width} and 0≤y<height0\le y<\text{height}. Walls are forbidden cells. Width and height are integers from 1 to 1000, and start and goal must be valid traversable cells. The output is a list of coordinates from start through goal, or None when no path exists. Path cost is the number of edges, one less than the number of listed cells.

The problem does not include diagonal moves, terrain weights, moving obstacles, multi-agent collision avoidance, or navigation meshes. Those changes require different neighbor rules or heuristics and cannot be added by renaming a function. A path valid on one immutable grid snapshot may become invalid after the world changes; the caller must define that update boundary.

Architecture and method choice#

Separate a validated immutable Grid, neighbor generation, A*, a simple BFS distance oracle, and a JSON CLI. A* maintains the best known cost g(n)g(n) and orders its frontier by f(n)=g(n)+h(n)f(n)=g(n)+h(n). For the four-neighbor unit-cost model, use Manhattan distance

h(x,y)=∣x−xg∣+∣y−yg∣.h(x,y)=|x-x_g|+|y-y_g|.

Every move changes one coordinate by one, so any path needs at least that many moves to close the coordinate gaps. Walls can make the true path longer but cannot make this lower bound too large. Thus the heuristic is admissible. For adjacent cells, h(u)≤1+h(v)h(u)\le1+h(v) by the triangle inequality, giving consistency.

The heap may contain an older entry for a node whose best cost was later improved. Store both cost and priority and skip an entry when its stored cost no longer equals the best known cost. Parent links reconstruct the selected path. Do not mark a node permanently solved merely because it was first discovered; discovery and optimal settlement are different events.

Distances on a grid. Each open cell shows its distance in moves from SS, the numbers the project’s breadth-first oracle computes; dark cells are walls. The highlighted path from SS to GG takes 17 moves. The Manhattan distance, 11, never exceeds the true distance, which is what lets A* use it.

Milestones and acceptance criteria#

First validate coordinates and neighbors. Next implement BFS and check tiny grids by hand. Then implement A* with parent reconstruction and compare returned cost to BFS on many generated small grids. Add the CLI only after the algorithms are independently testable. Finally measure representative map families, retaining unreachable cases and validating every timed result.

For every returned path, assert correct endpoints, in-bounds traversable cells, four-neighbor adjacency, and cost equal to BFS’s shortest distance. If A* returns no path, BFS must also report unreachable. Include start equals goal, a blocked or out-of-bounds endpoint, no walls, a corridor, a disconnected goal, duplicate wall input handling, and a route that initially moves away from the goal.

The supplied fixture’s shortest path costs 13. Different shortest paths are acceptable; exact coordinate-list equality is not required unless tie-breaking is explicitly part of the interface. The reference uses deterministic tuple ordering, but the semantic contract concerns validity and optimal cost.

J02ProjectSolution

Implement starters/pathfinding.py. Provide the immutable grid model, four-neighbor A*, independent BFS distance, and JSON command-line interface. Pass the generated-grid comparisons and produce a report proving Manhattan admissibility/consistency, describing stale-heap handling, and explaining at least one rejected extension. Include a reproducible benchmark methodology even if A* is not faster on your selected small cases.

Optional small hints

Before optimizing, draw a 3×33\times3 grid and compare actual paths with Manhattan distance. Use BFS as a reference only because every allowed edge has the same cost.

Complete solutions: J02#

Compare the first step where your work differs. A corrected answer is not yet an independent success.

J02Show solution

Algorithm, proof obligations, and complete reference

The reference grid validates dimensions and wall coordinates once. Its neighbor generator centralizes the movement model so A* and BFS solve the same graph. This shared model is appropriate, but independently test neighbor behavior: a mistake in the shared model could fool both algorithms. Their frontier algorithms are separate, so the tests can detect many search-specific mistakes.

A* stores the best discovered gg cost. When it pops a nonstale goal entry, no frontier path can lead to a cheaper goal under the admissible heuristic and the algorithm’s cost-improvement policy. To see the contradiction idea, suppose a cheaper path existed; along it, a frontier node would have g+hg+h no greater than that cheaper total, so the more expensive goal could not be the lowest-priority valid choice. Consistency additionally makes the heuristic well behaved across each unit edge. The code does not rely on an unsafe closed-set shortcut that forbids all later improvements.

Termination follows from the finite grid and nonnegative integer path costs with improvements to stored best values; the standard search analysis bounds explored graph work. For this bounded-degree grid, E=O(V)E=O(V) and heap-based search has a worst-case bound on the order of O(Vlog⁡V)O(V\log V), with O(V)O(V) stored state apart from implementation-dependent transient heap entries. BFS is O(V+E)=O(V)O(V+E)=O(V) and may outperform A* on some small or obstructed maps despite A*’s heuristic guidance. Fewer explored nodes is not automatically less elapsed time.

Back to J02

Complete reference implementation#

code/solutions/pathfinding.pyPython
"""J02: four-neighbor unit-cost A*, checked against an independent BFS."""
import argparse
from collections import deque
from dataclasses import dataclass
import heapq
import json
from pathlib import Path
import sys
Point = tuple[int, int]

@dataclass(frozen=True)
class Grid:
    width: int
    height: int
    walls: frozenset[Point]

    def __post_init__(self):
        if any(type(n) is not int or not 1 <= n <= 1000 for n in (self.width, self.height)):
            raise ValueError("dimensions must be integers in [1,1000]")
        if not isinstance(self.walls, frozenset) or any(not self.inside(p) for p in self.walls):
            raise ValueError("walls must be an in-bounds frozenset of integer coordinates")

    def inside(self, p: Point) -> bool:
        return (len(p) == 2 and all(type(x) is int for x in p)
                and 0 <= p[0] < self.width and 0 <= p[1] < self.height)

    def neighbors(self, p: Point):
        x, y = p
        for q in ((x+1, y), (x, y+1), (x-1, y), (x, y-1)):
            if self.inside(q) and q not in self.walls:
                yield q

    def validate_endpoints(self, start: Point, goal: Point):
        if any(not self.inside(p) or p in self.walls for p in (start, goal)):
            raise ValueError("endpoints must be in bounds and traversable")

def astar(grid: Grid, start: Point, goal: Point) -> list[Point] | None:
    grid.validate_endpoints(start, goal)
    def h(p): return abs(p[0]-goal[0]) + abs(p[1]-goal[1])
    best, parent = {start: 0}, {}
    heap = [(h(start), 0, start)]
    while heap:
        _, cost, u = heapq.heappop(heap)
        if cost != best[u]: continue
        if u == goal:
            path = [u]
            while u != start:
                u = parent[u]
                path.append(u)
            return path[::-1]
        for v in grid.neighbors(u):
            new = cost + 1
            if new < best.get(v, float("inf")):
                best[v], parent[v] = new, u
                heapq.heappush(heap, (new+h(v), new, v))
    return None

def bfs_distance(grid: Grid, start: Point, goal: Point) -> int | None:
    grid.validate_endpoints(start, goal)
    todo, seen = deque([(start, 0)]), {start}
    while todo:
        u, distance = todo.popleft()
        if u == goal: return distance
        for v in grid.neighbors(u):
            if v not in seen:
                seen.add(v)
                todo.append((v, distance+1))
    return None

def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("json", type=Path)
    args = parser.parse_args(argv)
    try:
        obj = json.loads(args.json.read_text())
        grid = Grid(obj["width"], obj["height"], frozenset(map(tuple, obj["walls"])))
        path = astar(grid, tuple(obj["start"]), tuple(obj["goal"]))
        print(json.dumps({"path": path, "cost": None if path is None else len(path)-1}))
    except (OSError, KeyError, TypeError, ValueError) as error:
        print(str(error), file=sys.stderr)
        return 2
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

Run and validate#

python -m solutions.pathfinding fixtures/grid.json
python -m unittest discover -s tests -v

The fixture report must contain cost 13 and a valid path. The test suite also compares 120 generated small-grid cases against BFS in a fixed seeded process. This is bounded randomized verification, not a proof for every grid. The algorithmic argument and the tests address different obligations.

Failure analysis and design tradeoffs#

Allowing diagonal moves while retaining Manhattan distance can make the heuristic overestimate: from (0,0)(0,0) to (1,1)(1,1), a unit-cost diagonal needs one move while Manhattan predicts two. Use a heuristic appropriate to the revised movement costs. Introducing negative edge costs invalidates the ordinary A*/Dijkstra assumptions and changes the problem substantially. Dynamic obstacles require snapshot consistency or replanning; sharing a mutable wall set with a running search without synchronization breaks the model.

An unreachable path is not an exceptional parser failure. It is a valid answer for a valid graph, represented by None and JSON null cost. An invalid endpoint is a contract violation and produces an error. Keeping these distinct makes the caller’s recovery behavior clear.

For benchmarking, report map dimensions, wall-generation method, seed, reachability distribution, start/goal policy, repeated samples, and whether parsing is timed. Validate costs before accepting timings. No universal A* speedup is claimed here, and the reference correctness tests are not themselves a pathfinding performance study.

Evaluation rubric and unassigned extensions#

Use the five dimensions from J01. Highest reasoning credit requires a heuristic proof and a clear stale-entry explanation. Highest verification credit requires path validity checks as well as cost comparison; comparing only lengths can miss an illegal route. Highest communication credit states the immutable-world assumption and distinguishes unreachable from malformed input. Weighted terrain, bidirectional search, hierarchical maps, and dynamic replanning are unassigned extensions requiring new proofs and tests, not features claimed complete in this reference.

Retention and readiness#

Retrieve C08 shortest-path assumptions and C14 oracle independence. Advance when you can adapt the movement model and identify which parts of the correctness argument must change, rather than applying A* by name to every navigation problem.