Rat with a fake blackboardTest a
Ratwith a fake blackboard. BecauseRatdepends only on theRecorderProtocol, you can drive it with a stand-in. Write a fake whoseclaim()returns a scripted sequence of results and whosespawn()records the coordinates it receives instead of starting a rat, run one rat withasyncio.run(rat.run()), and assert which cell the rat kept for itself and which cells it spawned. You need no realBlackboard,Maze, or task scheduling.
The
Rat and the Blackboard shows Rat calling only
the methods of the Recorder Protocol.
Write a class with those methods, so it satisfies the
Protocol by shape. Make claim() return
values from an iterator you script, and have
spawn() append to a list. After
asyncio.run(rat.run()), assert on the rat’s
position and on that list.
If you call next(self.claim_results) without the
False default, the fake works for the rat’s first
turn and fails on its second. The fifth claim()
finds the script empty, so next() raises a
StopIteration inside the coroutine, and Python
turns it into a RuntimeError (“coroutine raised
StopIteration”) that fails the test. The default lets the fake
answer False once the script runs out, so the rat
dead-ends the way it would against walls.
# test_ch38_fake_blackboard.py
import asyncio
from dataclasses import dataclass, field
from typing import Final, Protocol
DIRECTIONS: Final[list[tuple[int, int]]] = [
(0, 1), (0, -1), (-1, 0), (1, 0)]
class Recorder(Protocol):
def claim(self, x: int, y: int) -> bool: ...
def spawn(self, x: int, y: int) -> None: ...
def log(self, message: str) -> None: ...
def next_number(self) -> int: ...
@dataclass
class Rat:
blackboard: Recorder
x: int
y: int
number: int = field(init=False)
def __post_init__(self) -> None:
self.number = self.blackboard.next_number()
self.blackboard.log(
f"Rat {self.number} starts at "
f"{(self.x, self.y)}.")
async def run(self) -> None:
while True:
neighbors = [(self.x + dx, self.y + dy)
for dx, dy in DIRECTIONS]
moves = [pos for pos in neighbors
if self.blackboard.claim(*pos)]
if not moves:
self.blackboard.log(
f"Rat {self.number} dead-ends "
f"at {(self.x, self.y)}.")
return
for branch in moves[1:]:
self.blackboard.spawn(*branch)
self.x, self.y = moves[0]
await asyncio.sleep(0) # Sibling rats can run
class FakeBlackboard:
def __init__(self, claim_results: list[bool]) -> None:
self.claim_results = iter(claim_results)
self.spawned: list[tuple[int, int]] = []
self.messages: list[str] = []
def claim(self, x: int, y: int) -> bool:
return next(self.claim_results, False)
def spawn(self, x: int, y: int) -> None:
self.spawned.append((x, y))
def log(self, message: str) -> None:
self.messages.append(message)
def next_number(self) -> int:
return 1
def test_rat_keeps_one_claim_and_spawns_the_rest() -> None:
# DIRECTIONS tests (0,1), (0,-1), (-1,0), (1,0) in that
# order. Script the 2nd and 4th as open, the 1st and
# 3rd as walls or visited:
fake = FakeBlackboard([False, True, False, True])
rat = Rat(fake, 0, 0)
asyncio.run(rat.run())
# Kept the first successful claim
assert (rat.x, rat.y) == (0, -1)
# Spawned a rat at every claim after that
assert fake.spawned == [(1, 0)]
assert fake.messages == [
"Rat 1 starts at (0, 0).",
"Rat 1 dead-ends at (0, -1)."]Stand in for the blackboard.
Rat imports only the Recorder
Protocol, not Blackboard, so
FakeBlackboard satisfies that Protocol
by shape: it defines claim(), spawn(),
log(), and next_number(), and none of
the four touches a real Maze or
asyncio.create_task().
Script the rat’s choices. Scripting
claim()’s return values in a fixed sequence decides
which neighbor the rat keeps for itself and into which cells it
spawns new rats: the first cell the loop finds open,
(0, -1), and every open one after that, here
(1, 0) alone.
Stop the rat when the script ends. Once the
script runs out, claim() answers False
to everything, so the rat dead-ends on its second turn and
run() returns. The test needs no randomness and no
real maze.
Report the cells no rat reaches. After
explore()finishes, compareblackboard.visitedagainst every open cell of theMazeand print the open cells that no rat claimed. Build a maze for which that set is not empty, and explain what makes a cell unreachable.
Running the
Maze shows explore() starting every rat from
one entry cell and recording what the rats claim in
blackboard.visited. Build a set of every open cell
in the Maze and subtract visited from
it. To make the difference non-empty, draw a maze whose open
cells split into regions that no open path joins. Then ask where
every rat begins.
# The shape of exercise_2.py
import asyncio
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Final, Self
type Coord = tuple[int, int]
DIRECTIONS: Final[list[tuple[int, int]]] = [
(0, 1), (0, -1), (-1, 0), (1, 0)]
class Maze:
class Cell(StrEnum):
WALL = "*"
OPEN = " "
def __init__(self, rows: list[str]) -> None:
...
@classmethod
def from_text(cls, text: str) -> Self:
...
def is_open(self, x: int, y: int) -> bool:
...
def entry(self) -> Coord:
...
@dataclass
class Rat:
blackboard: Blackboard
x: int
y: int
async def run(self) -> None:
...
@dataclass
class Blackboard:
maze: Maze
visited: set[Coord] = field(init=False,
default_factory=set)
group: asyncio.TaskGroup = field(init=False)
def claim(self, x: int, y: int) -> bool:
...
def spawn(self, x: int, y: int) -> None:
...
async def explore(self) -> None:
...
async def main() -> None:
...# exercise_2.py
import asyncio
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Final, Self
type Coord = tuple[int, int]
DIRECTIONS: Final[list[tuple[int, int]]] = [
(0, 1), (0, -1), (-1, 0), (1, 0)]
class Maze:
class Cell(StrEnum):
WALL = "*"
OPEN = " "
def __init__(self, rows: list[str]) -> None:
self.height = len(rows)
self.width = max((len(r) for r in rows), default=0)
self.rows = [
r.ljust(self.width, self.Cell.WALL)
for r in rows]
@classmethod
def from_text(cls, text: str) -> Self:
rows = [line for line in text.splitlines() if line]
return cls(rows)
def is_open(self, x: int, y: int) -> bool:
return (0 <= y < self.height and 0 <= x < self.width
and self.rows[y][x] == self.Cell.OPEN)
def entry(self) -> Coord:
for y in range(self.height):
for x in range(self.width):
if self.is_open(x, y):
return x, y
raise ValueError("the maze has no open cell")
@dataclass
class Rat:
blackboard: Blackboard
x: int
y: int
async def run(self) -> None:
while True:
neighbors = [
(self.x + dx, self.y + dy)
for dx, dy in DIRECTIONS]
moves = [pos for pos in neighbors
if self.blackboard.claim(*pos)]
if not moves:
return
for branch in moves[1:]:
self.blackboard.spawn(*branch)
self.x, self.y = moves[0]
await asyncio.sleep(0)
@dataclass
class Blackboard:
maze: Maze
visited: set[Coord] = field(init=False,
default_factory=set)
group: asyncio.TaskGroup = field(init=False)
def claim(self, x: int, y: int) -> bool:
if (self.maze.is_open(x, y)
and (x, y) not in self.visited):
self.visited.add((x, y))
return True
return False
def spawn(self, x: int, y: int) -> None:
self.group.create_task(Rat(self, x, y).run())
async def explore(self) -> None:
start = self.maze.entry()
self.claim(*start)
async with asyncio.TaskGroup() as group:
self.group = group
self.spawn(*start)
two_rooms: Final[str] = """
*********
* * *
* * *
* * *
*********
"""
async def main() -> None:
maze = Maze.from_text(two_rooms)
board = Blackboard(maze)
await board.explore()
all_open = {(x, y) for y in range(maze.height)
for x in range(maze.width)
if maze.is_open(x, y)}
unreached = all_open - board.visited
print(len(unreached), min(unreached), max(unreached))
asyncio.run(main())
#: 9 (5, 1) (7, 3)Reuse the chapter’s classes. The classes are
the chapter’s, trimmed of what the exercise does not need: rat
numbers, logging, and the file loader. The structure that
matters survives the trim. claim() keeps the
chapter’s body word for word, and explore() still
opens a TaskGroup and lets spawn() add
tasks to that group, because new rats keep arriving after the
block begins.
Split the open cells into two regions. For a maze built with two separate rooms and no connecting opening between them:
*********
* * *
* * *
* * *
*********
the rats, starting in the left room, map every cell of that
room and none of the right room’s, so unreached is
the right room’s nine open cells. A cell is unreachable when no
path of open cells connects it to the entry, not when a wall
surrounds it. Maze.entry() scans row by row and
returns the first open cell it finds, and every rat traces back
to that single starting point through claim(). No
rat therefore reaches a cell that has no open-cell path back to
the entry, however many rats spawn.
claim()’s atomicityBreak the atomicity of
claim(). Makeclaim()anasync def, which forces matching changes in theRecorderprotocol,Rat.run()’s comprehension, andexplore(). Putawait asyncio.sleep(0)between the membership test andself.visited.add(...). Then count how many calls returnTrueand compare that count withlen(blackboard.visited), using a maze that contains a loop.amaze.txtis a perfect maze, so no two rats reach one unclaimed cell and the counts always agree.test_rats_and_mazes.pystill passes, becausevisitedis a set. The guarantee that broke is “one rat per cell”, not “every cell visited”. What happens to the two rats that both claimed one cell, and why does the originalclaim(), with noawaitinside it, need no lock?
Contention
on a Loop shows two rats reaching one unclaimed cell, and The
Rat and the Blackboard shows the claim() you
are changing. Making claim() an
async def means each caller must await
it, which spreads through the Protocol, the
comprehension, and explore(). Count the
True results in a field on the
Blackboard and compare the count with
len(visited). A coroutine gives up control only at
an await, which decides whether
claim() needs a lock.
# The shape of exercise_3.py
import asyncio
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Final, Protocol, Self
type Coord = tuple[int, int]
DIRECTIONS: Final[list[tuple[int, int]]] = [
(0, 1), (0, -1), (-1, 0), (1, 0)]
LAYOUT: Final[str] = """\
*********
* *
*** *** *
* * *
* ***** *
* *
*********
"""
class Maze:
class Cell(StrEnum):
WALL = "*"
OPEN = " "
def __init__(self, rows: list[str]) -> None:
...
@classmethod
def from_text(cls, text: str) -> Self:
...
def is_open(self, x: int, y: int) -> bool:
...
def entry(self) -> Coord:
...
class Recorder(Protocol):
async def claim(self, x: int, y: int) -> bool: ...
def spawn(self, x: int, y: int) -> None: ...
@dataclass
class Rat:
blackboard: Recorder
x: int
y: int
async def run(self) -> None:
...
@dataclass
class Blackboard:
maze: Maze
visited: set[Coord] = field(init=False,
default_factory=set)
true_claims: int = field(init=False, default=0)
group: asyncio.TaskGroup = field(init=False)
async def claim(self, x: int, y: int) -> bool:
...
def spawn(self, x: int, y: int) -> None:
...
async def explore(self) -> None:
...
async def main() -> None:
...# The shape of robot_world.py
from enum import Enum, auto
from itertools import groupby
from typing import ClassVar, Final, override
class Urge(Enum):
NORTH = auto()
SOUTH = auto()
EAST = auto()
WEST = auto()
class Item:
symbol: ClassVar[str] = ""
def interact(self, robot: Robot, room: Room) -> Room:
...
def __str__(self) -> str:
...
class Robot(Item):
symbol: ClassVar[str] = "R"
# Set by the builder when the robot is placed
room: Room
def __init__(self) -> None:
...
def move(self, urge: Urge) -> None:
...
class Wall(Item):
symbol: ClassVar[str] = "#"
@override
def interact(self, robot: Robot, room: Room) -> Room:
...
class Food(Item):
symbol: ClassVar[str] = "."
@override
def interact(self, robot: Robot, room: Room) -> Room:
...
class Teleport(Item):
symbol: ClassVar[str] = "" # Shown as its target letter
target_room: Room # Paired up by the builder
def __init__(self, target: str) -> None:
...
@override
def interact(self, robot: Robot, room: Room) -> Room:
...
@override
def __str__(self) -> str:
...
class Empty(Item):
symbol: ClassVar[str] = "_"
class Edge(Item):
symbol: ClassVar[str] = "/"
@override
def interact(self, robot: Robot, room: Room) -> Room:
# The void outside the maze: stay put
...
class EndGame(Item):
symbol: ClassVar[str] = "!"
@override
def interact(self, robot: Robot, room: Room) -> Room:
...
def item_factory(symbol: str) -> Item:
...
type Coord = tuple[int, int]
type RoomMap = dict[Coord, Room]
class Room:
def __init__(self, occupant: Item) -> None:
...
def enter(self, robot: Robot) -> Room:
...
def __repr__(self) -> str:
...
class Doors:
def __init__(self) -> None:
...
def connect(self, row: int, col: int,
rooms: RoomMap) -> None:
...
def open(self, urge: Urge) -> Room:
...
EDGE: Final[Room] = Room(Edge())
class GameBuilder:
def __init__(self, maze: str) -> None:
...
def run(self, solution: str) -> None:
...If you test the broken claim() on
amaze.txt, the count of True returns
equals len(visited) on every run, and the gap looks
harmless. A perfect maze offers one path to each cell, so no two
rats reach one unclaimed cell. The solution uses the
seven-by-nine maze from test_rats_and_mazes.py,
whose loop lets two rats approach one cell from opposite
directions.
# exercise_3.py
import asyncio
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Final, Protocol, Self
type Coord = tuple[int, int]
DIRECTIONS: Final[list[tuple[int, int]]] = [
(0, 1), (0, -1), (-1, 0), (1, 0)]
LAYOUT: Final[str] = """\
*********
* *
*** *** *
* * *
* ***** *
* *
*********
"""
class Maze:
class Cell(StrEnum):
WALL = "*"
OPEN = " "
def __init__(self, rows: list[str]) -> None:
self.height = len(rows)
self.width = max((len(r) for r in rows), default=0)
self.rows = [
r.ljust(self.width, self.Cell.WALL)
for r in rows]
@classmethod
def from_text(cls, text: str) -> Self:
rows = [line for line in text.splitlines() if line]
return cls(rows)
def is_open(self, x: int, y: int) -> bool:
return (0 <= y < self.height and 0 <= x < self.width
and self.rows[y][x] == self.Cell.OPEN)
def entry(self) -> Coord:
for y in range(self.height):
for x in range(self.width):
if self.is_open(x, y):
return x, y
raise ValueError("the maze has no open cell")
class Recorder(Protocol):
async def claim(self, x: int, y: int) -> bool: ...
def spawn(self, x: int, y: int) -> None: ...
@dataclass
class Rat:
blackboard: Recorder
x: int
y: int
async def run(self) -> None:
while True:
neighbors = [
(self.x + dx, self.y + dy)
for dx, dy in DIRECTIONS]
moves = [pos for pos in neighbors
if await self.blackboard.claim(*pos)]
if not moves:
return
for branch in moves[1:]:
self.blackboard.spawn(*branch)
self.x, self.y = moves[0]
await asyncio.sleep(0)
@dataclass
class Blackboard:
maze: Maze
visited: set[Coord] = field(init=False,
default_factory=set)
true_claims: int = field(init=False, default=0)
group: asyncio.TaskGroup = field(init=False)
async def claim(self, x: int, y: int) -> bool:
if (self.maze.is_open(x, y)
and (x, y) not in self.visited):
# The gap: another rat can run
await asyncio.sleep(0)
self.visited.add((x, y))
self.true_claims += 1
return True
return False
def spawn(self, x: int, y: int) -> None:
self.group.create_task(Rat(self, x, y).run())
async def explore(self) -> None:
start = self.maze.entry()
await self.claim(*start)
async with asyncio.TaskGroup() as group:
self.group = group
self.spawn(*start)
async def main() -> None:
board = Blackboard(Maze.from_text(LAYOUT))
await board.explore()
print("claims that returned True:", board.true_claims)
print("cells visited:", len(board.visited))
asyncio.run(main())
#: claims that returned True: 25
#: cells visited: 24Carry the async to every
caller. The one requested change drags three more edits
with it, and that spread is the exercise’s quiet lesson:
async is contagious. Once claim() is
an async def, the Recorder protocol
must declare it async too, Rat.run()’s
comprehension needs
if await self.blackboard.claim(*pos), and
explore() must await its own first
claim. spawn() stays synchronous, because nothing
in it suspends.
Let two rats claim one cell. On the
chapter’s seven-by-nine test maze, claim() returns
True 25 times for 24 open cells: one pair of rats
collided. Both rats reach await asyncio.sleep(0)
while the same cell still looks unclaimed, because neither has
added that cell to visited yet. Both membership
tests therefore pass before either rat calls
self.visited.add(...). Each of the two rats
believes it alone claimed that cell. Both move into it, and that
overlap breaks the invariant that no two rats cover the same
ground. Nothing goes unexplored. Both rats proceed from the
shared cell and duplicate each other’s work from there, while
visited stays correct, because adding the same cell
twice to a set changes nothing. That correctness is why test_rats_and_mazes.py
passes on the broken version every time: the test asserts the
set of cells reached. The extra True costs the rats
wasted effort, two tasks tracing overlapping paths. Comparing
the count of True returns with the size of
visited exposes the collision.
The original claim() needs no lock because it
has no await between the test and the add. A
coroutine yields control only at an await, so the
two statements run as one uninterruptible unit: the event loop
can hand control to another rat before the test or after the
add, but not between them. Adding the await opens
that gap in the middle, and the whole guarantee depends on the
gap’s absence.
Exercises 4 and 5 both build on the same
robot_explorer world, so
robot_world.py holds that shared apparatus once
(Item and its subclasses, Room,
Doors, GameBuilder), and each exercise
imports the module:
# robot_world.py
from enum import Enum, auto
from itertools import groupby
from typing import ClassVar, Final, override
class Urge(Enum):
NORTH = auto()
SOUTH = auto()
EAST = auto()
WEST = auto()
class Item:
symbol: ClassVar[str] = ""
def interact(self, robot: Robot, room: Room) -> Room:
return room # Default: the robot enters the room
def __str__(self) -> str:
return self.symbol
class Robot(Item):
symbol: ClassVar[str] = "R"
# Set by the builder when the robot is placed
room: Room
def __init__(self) -> None:
self.finished = False
# Exercise 4: a place to count Coin pickups
self.coins = 0
def move(self, urge: Urge) -> None:
self.room = self.room.doors.open(urge).enter(self)
class Wall(Item):
symbol: ClassVar[str] = "#"
@override
def interact(self, robot: Robot, room: Room) -> Room:
return robot.room # Cannot pass: stay put
class Food(Item):
symbol: ClassVar[str] = "."
@override
def interact(self, robot: Robot, room: Room) -> Room:
room.occupant = Empty() # Eaten
return room
class Teleport(Item):
symbol: ClassVar[str] = "" # Shown as its target letter
target_room: Room # Paired up by the builder
def __init__(self, target: str) -> None:
self.target = target
@override
def interact(self, robot: Robot, room: Room) -> Room:
return self.target_room
@override
def __str__(self) -> str:
return self.target
class Empty(Item):
symbol: ClassVar[str] = "_"
class Edge(Item):
symbol: ClassVar[str] = "/"
@override
def interact(self, robot: Robot, room: Room) -> Room:
# The void outside the maze: stay put
return robot.room
class EndGame(Item):
symbol: ClassVar[str] = "!"
@override
def interact(self, robot: Robot, room: Room) -> Room:
robot.finished = True
return room
def item_factory(symbol: str) -> Item:
for item_type in Item.__subclasses__():
if symbol == item_type.symbol:
return item_type()
# Anything else is a teleport target
return Teleport(symbol)
type Coord = tuple[int, int]
type RoomMap = dict[Coord, Room]
class Room:
def __init__(self, occupant: Item) -> None:
self.occupant = occupant
self.doors = Doors()
def enter(self, robot: Robot) -> Room:
return self.occupant.interact(robot, self)
def __repr__(self) -> str:
return f"Room({self.occupant})"
class Doors:
def __init__(self) -> None:
self.neighbors: dict[Urge, Room] = {}
def connect(self, row: int, col: int,
rooms: RoomMap) -> None:
for urge, coord in {
Urge.NORTH: (row - 1, col),
Urge.SOUTH: (row + 1, col),
Urge.EAST: (row, col + 1),
Urge.WEST: (row, col - 1),
}.items():
if coord in rooms:
self.neighbors[urge] = rooms[coord]
def open(self, urge: Urge) -> Room:
return self.neighbors.get(urge, EDGE)
EDGE: Final[Room] = Room(Edge())
class GameBuilder:
def __init__(self, maze: str) -> None:
self.rooms: RoomMap = {}
teleports: list[Room] = []
for row, line in enumerate(maze.splitlines()):
for col, char in enumerate(line):
occupant = item_factory(char)
if isinstance(occupant, Robot):
room = Room(Empty())
self.robot = occupant
self.robot.room = room
else:
room = Room(occupant)
self.rooms[row, col] = room
if isinstance(occupant, Teleport):
teleports.append(room)
for (row, col), room in self.rooms.items():
room.doors.connect(row, col, self.rooms)
def target(room: Room) -> str:
assert isinstance(room.occupant, Teleport)
return room.occupant.target
teleports.sort(key=target)
for letter, group in groupby(teleports, key=target):
pair = list(group)
assert len(pair) == 2, letter
room1, room2 = pair
assert isinstance(room1.occupant, Teleport)
assert isinstance(room2.occupant, Teleport)
room1.occupant.target_room = room2
room2.occupant.target_room = room1
def run(self, solution: str) -> None:
moves = {"n": Urge.NORTH, "s": Urge.SOUTH,
"e": Urge.EAST, "w": Urge.WEST}
for char in "".join(solution.split()):
self.robot.move(moves[char])Coin itemAdd a new kind of
Itemto the robot maze. Define aCoinsubclass ofItemwith the symbol$. Itsinteract()removes the coin from its room, asFood’s does for the food, and adds one to a coin count carried by theRobot. Place a few$characters in the maze and report how many the robot collects.item_factory(),Room, andGameBuilderstay as they are. Explain why the factory finds your new item on its own, and what the factory does if you deriveCoinfromFoodinstead.
Rooms,
Robots, and the Item Factory shows how
Food.interact() replaces its own occupant and how
item_factory() finds an Item class
from its symbol. Give Coin the
$ symbol, an interact() that does the
same replacement, and a count on Robot. To see why
the factory finds Coin by itself, read which
classes Item.__subclasses__() returns. Then check
whether a class derived from Food appears in that
list.
# The shape of exercise_4.py
from typing import ClassVar, override
from robot_world import (Empty, GameBuilder, Item,
Robot, Room)
class Coin(Item):
symbol: ClassVar[str] = "$"
@override
def interact(self, robot: Robot, room: Room) -> Room:
...# exercise_4.py
from typing import ClassVar, override
from robot_world import (Empty, GameBuilder, Item,
Robot, Room)
class Coin(Item):
symbol: ClassVar[str] = "$"
@override
def interact(self, robot: Robot, room: Room) -> Room:
room.occupant = Empty() # Collected, like Food
robot.coins += 1
return room
game = GameBuilder("#####\nR$$.#\n#####")
game.run("ee")
print(game.robot.coins)
#: 2Register the item by subclassing.
item_factory() needs no change. It searches
Item.__subclasses__() for a class whose
symbol matches the character it receives, and
__subclasses__() reports the subclasses that exist
right now, so class Coin(Item) in
exercise_4.py puts Coin on the list
the factory searches.
Act through the shared interface.
Room and GameBuilder need no change
either, since both call
occupant.interact(robot, room) through the shared
Item interface. Neither one needs to know which
concrete Item subclasses exist.
Give the robot a counter.
Robot.__init__() needs one new line,
self.coins = 0, to have somewhere to count (folded
into robot_world.py above so this exercise’s file
stays a single, runnable unit).
Deriving Coin from Food instead
breaks the maze, and the reason is where the factory searches,
not what Coin inherits. item_factory()
walks Item.__subclasses__(), which lists only the
direct subclasses of Item, so a
Coin(Food) is absent from that list. No entry
matches $, and the loop falls through to the
factory’s last line, which treats any unrecognized symbol as a
teleport target. So item_factory("$") returns
Teleport("$"). The two $ cells become
a teleport pair, the robot walks into a teleporter where the
maze should hold a coin, the topology changes underneath the
hard-coded route, and game.robot.coins stays
0. A one-word change to a class header moves a
character out of the factory’s search and silently substitutes a
different Item.
!Send the robot to something other than the
!.solve()stops at whatever room holds anEndGame, the one goal it can express. Replace thatisinstance()test with aCallable[[Room], bool]parameter, so the caller says what counts as arriving, and change nothing else in the search, beyond lettingsolve()returnNonewhen no room matches. Then use the new parameter to feed the robot: search for the nearest room holding aFood, walk there, and repeat until noFoodremains, then search for the!and walk the route the search finds. Report how many pieces of food the robot ate and how many moves the whole tour took. The run answers two questions for you. Why must the search run again after every meal instead of once at the start? And why does asking for the nearest food each time not produce the shortest tour that eats everything?
Choosing
the Path shows solve() searching breadth-first
and stopping at the room that holds an EndGame.
Replace that isinstance() test with a call to the
Callable[[Room], bool] parameter, and return
None when the queue empties. Write one small
predicate function for food and another for the !.
Loop with the walrus operator,
while (leg := solve(...)) is not None, and walk
each leg with run().
# The shape of exercise_5.py
from collections import deque
from collections.abc import Callable
from typing import Final
from robot_world import (Edge, EndGame, Food, GameBuilder,
Room, Teleport, Urge, Wall)
MOVES: Final[dict[Urge, str]] = {
Urge.NORTH: "n", Urge.SOUTH: "s",
Urge.EAST: "e", Urge.WEST: "w"}
def landing(room: Room, urge: Urge) -> Room | None:
...
def solve(game: GameBuilder,
arrived: Callable[[Room], bool]) -> str | None:
...
def food(room: Room) -> bool:
...
def end(room: Room) -> bool:
...If you keep the chapter’s final
raise ValueError, the food loop cannot end
normally. After the last meal, the search for more food raises
the ValueError, and the script stops with a
traceback before the walk to the ! and before
either print(). Returning None makes
an empty search the loop’s ordinary exit.
# exercise_5.py
from collections import deque
from collections.abc import Callable
from typing import Final
from robot_world import (Edge, EndGame, Food, GameBuilder,
Room, Teleport, Urge, Wall)
MOVES: Final[dict[Urge, str]] = {
Urge.NORTH: "n", Urge.SOUTH: "s",
Urge.EAST: "e", Urge.WEST: "w"}
def landing(room: Room, urge: Urge) -> Room | None:
beyond = room.doors.open(urge)
if isinstance(beyond.occupant, Wall | Edge):
return None
if isinstance(beyond.occupant, Teleport):
return beyond.occupant.target_room
return beyond
def solve(game: GameBuilder,
arrived: Callable[[Room], bool]) -> str | None:
start = game.robot.room
queue: deque[tuple[Room, str]] = deque([(start, "")])
seen: set[Room] = {start}
while queue:
room, path = queue.popleft()
if arrived(room):
return path
for urge, char in MOVES.items():
beyond = landing(room, urge)
if beyond is None or beyond in seen:
continue
seen.add(beyond)
queue.append((beyond, path + char))
return None
def food(room: Room) -> bool:
return isinstance(room.occupant, Food)
def end(room: Room) -> bool:
return isinstance(room.occupant, EndGame)
string_maze = """
###############################
#R#.____#____.#_______#_______#
#_###_#_###_#_#_#_#####_#####_#
#___#_#___#_#_#_#.#__b__#___#_#
###_#_###_#_#_###_#_#####_#_#_#
#.#_#_#.__#_#__.#_#__b__#_#___#
#_#_#_#_###_###_#_#####_#_#####
#_#_#_#__.#_#_#_____#___#_____#
#_#_#_###_#_#_#_#####_#######_#
#.#___#___#_#___#____.#_____#_#
#_#####_###_#_###_#####_#_###_#
#___#a__#.__#.__#__.#___#_#___#
#_#_#_###_#####_###_###_###_#_#
#_#.#_#___#!______#_____#___#_#
#_#_#_###_#############_#_###_#
#_#_#__a#_______________#___#_#
#_#####_###_###########_###_#_#
#_____#.__#_#___#_____#_#___#_#
#_#_#####_###_#_#_###_###_###_#
#.#___________#___#____.__#___#
###############################
""".strip()
game = GameBuilder(string_maze)
meals = 0
moves = 0
while (leg := solve(game, food)) is not None:
game.run(leg)
meals += 1
moves += len(leg)
last = solve(game, end)
assert last is not None
game.run(last)
moves += len(last)
print(meals, "meals,", moves, "moves")
#: 16 meals, 282 moves
print("finished:", game.robot.finished)
#: finished: TrueLet the caller define arrival.
solve() changes in one place. The
isinstance(room.occupant, EndGame) test becomes
arrived(room), a predicate the caller supplies.
Nothing else in the search knows or cares what counts as
arriving. The EndGame version is now one line at
the call site, end, and food is
another.
Report an empty search as None.
The other change is the return type. The chapter’s version
raises a ValueError when the search runs out of
rooms, because a maze with no reachable ! is a
broken maze. Here, running out of rooms is the ordinary way the
food loop ends, so solve() returns
None and the walrus in the while reads
it as “nothing left to eat.”
Replan after every meal. The search must run
again after every meal because both of its ends move.
Food.interact() replaces the food with an
Empty(), so the room the robot just arrived at
stops being a goal, and the robot’s own room is now the new
start. A path planned from the entry is no use from any other
room, so one search at the start yields the first leg and no
more. Searching again costs little: each search touches at most
the maze’s 299 rooms that hold no wall.
Nearest-first does not give the shortest tour that eats everything. Choosing the closest food each time is a greedy choice made with no view of what comes after it, and the maze makes that costly. Two pieces of food can sit close together down one dead-end corridor while a third sits one step nearer in the opposite direction. Taking the single near one first means walking the corridor twice. The shortest complete tour is a travelling-salesman problem over the food rooms, and its first leg is often not the shortest leg available. The greedy tour does guarantee that every leg is a shortest path, which is all breadth-first search guarantees.
The last three exercises all shake the same plate, so this
file carries the chapter’s chladni.py once, with one
change: Plate takes the field function as a
constructor argument instead of calling the module-level
amplitude() directly. That argument makes exercise
7’s different physics a second function rather than an edit, so
both functions can run side by side in one program.
# chladni.py
import math
import random
from collections.abc import Callable
from dataclasses import dataclass
type Mode = tuple[int, int] # Vibration pattern (m, n)
type Field = Callable[[float, float, Mode], float]
def amplitude(x: float, y: float, mode: Mode) -> float:
m, n = mode
return abs(
math.cos(m * math.pi * x)
* math.cos(n * math.pi * y)
- math.cos(n * math.pi * x)
* math.cos(m * math.pi * y))
def membrane(x: float, y: float, mode: Mode) -> float:
m, n = mode
return abs(
math.sin(m * math.pi * x)
* math.sin(n * math.pi * y))
def bounce(v: float) -> float:
if v < 0.0:
return -v
if v > 1.0:
return 2.0 - v
return v
@dataclass
class Grain:
x: float
y: float
class Plate:
def __init__(self, grains: int, mode: Mode,
seed: int | None = None,
field: Field = amplitude) -> None:
self.rng = random.Random(seed)
self.mode = mode
self.field = field
self.grains = [
Grain(self.rng.random(), self.rng.random())
for _ in range(grains)]
def step(self, kick: float = 0.05) -> None:
for g in self.grains:
a = self.field(g.x, g.y, self.mode)
g.x = bounce(
g.x + self.rng.uniform(-kick, kick) * a)
g.y = bounce(
g.y + self.rng.uniform(-kick, kick) * a)
def agitation(self) -> float:
return sum(
self.field(g.x, g.y, self.mode)
for g in self.grains) / len(self.grains)
def render(self, width: int = 57,
height: int = 30) -> str:
counts: list[list[int]] = [
[0] * width for _ in range(height)]
for g in self.grains:
col = min(int(g.x * width), width - 1)
row = min(int(g.y * height), height - 1)
counts[row][col] += 1
shades = " .:*#"
return "\n".join(
"".join(shades[min(c, len(shades) - 1)]
for c in row).rstrip()
for row in counts)Freeze the plate. Run the Chladni view with
MODESstarting at(2, 2). Work out whatamplitude()returns wheneverm == n, and explain why the view shows neither chaos nor a figure. Then explain why the main diagonal shows up in every figure this plate makes. Swappingxandyin the two terms ofamplitude()is the clue.
The
Model shows step() scaling each grain’s random
displacement by amplitude(). Substitute
m == n into the two products of
amplitude() and compare them. For the diagonal,
swap x and y and compare the sign of
the difference inside abs() before and after. Then
ask what that implies where x == y.
# exercise_6.py
from chladni import Plate, amplitude
print(amplitude(0.31, 0.79, (2, 2)))
#: 0.0
plate = Plate(grains=2000, mode=(2, 2), seed=42)
before = [(g.x, g.y) for g in plate.grains]
for _ in range(1200):
plate.step()
after = [(g.x, g.y) for g in plate.grains]
print(f"agitation {plate.agitation():.3f}, "
f"moved {before != after}")
#: agitation 0.000, moved False
print(amplitude(0.37, 0.37, (1, 2)))
#: 0.0Probe the field at m == n. With
m == n, amplitude() returns zero
everywhere. Its two terms become cos(mπx)cos(mπy)
and cos(mπx)cos(mπy), the same product written
twice, and the function subtracts one from the other. Not
approximately zero: the two multiplications produce identical
floats, so the difference is exactly 0.0 at every
point on the plate.
Confirm that no grain moves. A zero field
means a zero kick. step() scales each grain’s
random displacement by the amplitude under that grain, so
uniform(-kick, kick) * 0.0 moves nothing, and 1200
steps leave every grain where the constructor scattered it. The
view shows neither chaos nor a figure because no grain moves: it
shows the initial random scatter, frozen. Agitation reads
0.000 from the first step, the same number a
perfectly settled plate reports, so the summary statistic cannot
tell “finished” from “never started.”
Probe a point on the diagonal. The main
diagonal in every figure follows from the same two terms.
Swapping x and y turns the first term
into the second and the second into the first, so the swap
reverses the subtraction inside amplitude()’s
abs(). On the line x == y the swap
changes nothing, so the subtraction there must equal its own
negation, which forces that value to zero. Every mode in which
this plate can ring therefore has a nodal line straight down the
main diagonal, and the figures all share that one feature no
matter which (m, n) produced them.
Change the physics. Replace the body of
amplitude()withabs(math.sin(m * math.pi * x) * math.sin(n * math.pi * y)), the standing waves of a membrane fixed at its edges, like a drumhead. Predict the figures before you run the view. Why are the nodal lines now straight?
What
the Numbers Show explains how agitation measures the plate
settling toward its nodal lines. Pass a second field function to
Plate and compare the figures. Write the new field
as a product of a function of x and a function of
y. A product is zero when either factor is zero, so
find where each sin factor vanishes.
# exercise_7.py
from chladni import Plate, membrane
plate = Plate(grains=2000, mode=(2, 3), seed=42,
field=membrane)
steps = 0
for target in (0, 100, 400, 1200):
for _ in range(target - steps):
plate.step()
steps = target
print(f"steps {target:4}: "
f"agitation {plate.agitation():.3f}")
#: steps 0: agitation 0.406
#: steps 100: agitation 0.100
#: steps 400: agitation 0.014
#: steps 1200: agitation 0.002
print(plate.render(width=40, height=20))
#: #:**# ######:#####..#:##*###############
#: # ## #
#: # ## .#
#: # . ## #
#: # ## #
#: # #* #
#: ######################*#################
#: # ## #
#: # ## #
#: # ## #
#: # ## #
#: # ## #
#: * .## . .#
#: ########################################
#: # ## #
#: # ## #
#: # ## #
#: # ## #
#: # . ## #
#: ##############**##:##.#:##############.#The figure is a grid: one vertical line down the middle of the plate and two horizontal lines cutting it into thirds, with the four edges filled in as well.
The nodal lines are straight because the new field is a
product of one function of x and one function of
y. The product vanishes when either factor does,
and sin(mπx) is zero at x = 0, 1/2, 1
for m = 2, regardless of y. Those
three zeros give vertical lines. sin(nπy) is zero
at y = 0, 1/3, 2/3, 1 for n = 3,
regardless of x, giving horizontal lines. Every
nodal point lies on one of those seven lines, and the interior
lines number m - 1 vertical and n - 1
horizontal, so the mode numbers are readable straight off the
picture.
The plate’s own field does not separate into a factor in
x times a factor in y. Each of its two
terms mixes x and y, and subtracting
one from the other leaves zeros along the curves where the two
products agree, which is why the original figures are diagonals,
crosses, and rings rather than a grid. Those mixed terms come
from the physics the chapter’s formula approximates, a real
plate with free edges rather than a membrane clamped all around
its rim. The simulation machinery stays the same across both
fields: same grains, same random walk, same rule that a grain
moves in proportion to the vibration under it. Only the field
changes, and with it every pattern the model produces.
Tune the noise. Rerun
chladni_demo.pypassingkick=0.005and thenkick=0.5toplate.step(), printing agitation at the same checkpoints. One setting produces order too slowly. The other drives agitation down as convincingly as the default kick, yet no figure appears. Explain both failures, and why an intermediate kick avoids them.
The
Model shows step() multiplying the random kick
by the amplitude, so a grain slows as it nears a nodal line.
Loop over the three kick values with a fresh Plate
for each, and print agitation at the same checkpoints. For the
large kick, compare a grain’s maximum single step with the size
of the plate. Watching
It Happen shows the rendered figure, which is the check that
agitation cannot make.
# exercise_8.py
from chladni import Plate
for kick in (0.005, 0.05, 0.5):
plate = Plate(grains=2000, mode=(2, 3), seed=42)
steps = 0
readings = []
for target in (0, 100, 400, 1200):
for _ in range(target - steps):
plate.step(kick=kick)
steps = target
readings.append(f"{plate.agitation():.2f}")
print(f"kick {kick:<5}: {' '.join(readings)}")
#: kick 0.005: 0.58 0.56 0.49 0.38
#: kick 0.05 : 0.58 0.07 0.00 0.00
#: kick 0.5 : 0.58 0.11 0.01 0.00kick=0.005 produces order too slowly. Each step
displaces a grain by at most one percent of the plate, so a
grain starting in the middle of a bright region needs hundreds
of steps to walk anywhere near a nodal line. After 1200 steps
agitation has fallen from 0.58 to
0.38, roughly a third of the way, while the default
kick was down to 0.00 by step 400. Rendered, this
run still looks like noise with a faint trace of structure in
it. Nothing is wrong with the physics. The run is not finished,
and finishing it means more steps than anyone wants to
watch.
kick=0.5 fails differently, and the agitation
column hides the failure: agitation collapses to
0.00 as convincingly as it does at the default
kick, and the figure does not appear. A kick of up to half the
plate, and the full width where the amplitude peaks at 2, can
throw a grain across the plate in one step, so a grain does not
walk toward the nearest nodal line. It jumps somewhere unrelated
and stays only if that spot is quiet. The spots that hold a
grain best are the two corners where the main diagonal ends. At
(0, 0) and (1, 1) the field is zero
and also flat, so it stays weak over a whole patch, where along
a nodal line it is weak only in a thin strip. Rendered, the run
shows nearly every grain in those two corners and the nodal
lines between them empty. The plate reports settled sand in the
wrong places.
Agitation measures whether the grains sit where the field is weak, not whether the figure is right, so one number cannot distinguish a sharp pattern from two blobs. The render is the check the number cannot perform.
An intermediate kick avoids both failures because the
amplitude scaling in step() is a feedback loop, and
the loop works within a range of step sizes and fails outside
it. A grain in a loud region gets a large kick and moves fast.
As it nears a nodal line the amplitude shrinks and so does its
step, so it slows down and stops without overshooting. Too small
a kick starves the loop’s first half, and the grain barely
travels. Too large a kick breaks the second half, since even a
heavily scaled step is still big enough to leave the
neighborhood into which the grain is settling. The default
0.05 sits where both halves work: at most a tenth
of the plate where the amplitude peaks, and vanishingly small
once a grain arrives.