A Roadmap That Answers Back
Thirty-nine chapters in, the plan lives in your head
Count what she is made of right now: a voice pipeline, a memory database, an automation engine, a mood system, a composed core with a startup health check, and a directory of lab scripts you wrote once and have not opened since. Ahead of that sit motors, printed parts, microphones, a Jetson. Every time you sit down, the same two questions come first: what is actually finished, and what does finishing the next piece unlock? Right now the answer to both lives in your memory, and your memory has been quietly wrong about this project before.
The usual fix is a checklist in a notes file, dated. Dates are the weakest part of it. A line that says "memory system done by April" tells you when to feel bad and never tells you what to build; miss it once and the whole file becomes a document you stop opening, because reading it costs you something and gives you nothing back. What you want from a plan is an answer, and a checkbox cannot answer. Somebody still has to look at each row and decide whether it is true.
So the rule for this chapter: a milestone names the chapters that satisfy it, so progress is computed from work you have finished instead of asserted by hand. A date stays on the record as a hint. The chapter range is the part with teeth, because a range is a set, and a set can be tested.
Chapter 26 built StrategicPlan for goals, and this chapter builds a
second container instead of adding fields to that one. The two records answer
different questions. A goal is something you intend to work on, so it carries
priority and a deadline and it is finished when you say it is. A milestone is a claim
about the state of the whole project ("her voice is done"), and a claim that only you
can verify is not much of a claim. Keeping them apart means neither type grows a
field that half its instances leave empty, which is how one honest dataclass turns
into a shrug with twelve optional attributes.
Model, contain, compute, save
# labs/roadmap.py
from dataclasses import dataclass, field
@dataclass
class Milestone:
title: str
description: str
first_chapter: int
last_chapter: int
status: str = "pending"
deliverables: list[str] = field(default_factory=list)
def chapters(self) -> set[int]:
return set(range(self.first_chapter, self.last_chapter + 1))
m = Milestone(
"Her voice",
"Speech in, model, speech out, in her own cloned voice",
1, 10,
deliverables=["labs/voice_loop.py", "glados/data/voice/reference.wav"],
)
print(f"{m.title}: chapters {m.first_chapter}-{m.last_chapter}, {m.status}")
print("satisfied by:", sorted(m.chapters()))
print("deliverables:", len(m.deliverables))
$ uv run python labs/roadmap.py
Her voice: chapters 1-10, pending
satisfied by: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
deliverables: 2
Two integers, not the string "1-10". Storing the endpoints and expanding
them in chapters() costs one method and buys arithmetic: the milestone
can be asked which chapters it covers, how many are left, and whether a given chapter
belongs to it. The deliverables list is the other half of honesty, naming
the files that should exist when the milestone is true, so "her voice is done" points
at something on disk instead of at a feeling. Exercise 3 turns that list into a second
completion test.
from datetime import datetime
@dataclass
class Roadmap:
name: str
milestones: list[Milestone] = field(default_factory=list)
created_at: str = ""
def __post_init__(self) -> None:
if not self.created_at:
self.created_at = datetime.now().isoformat()
def add_milestone(self, milestone: Milestone) -> None:
self.milestones.append(milestone)
def pending_milestones(self) -> list[Milestone]:
return [m for m in self.milestones if m.status == "pending"]
def progress(self) -> str:
total = len(self.milestones)
done = sum(1 for m in self.milestones if m.status == "complete")
pct = round(100 * done / total) if total else 0
return f"{done}/{total} milestones complete ({pct}%)"
roadmap = Roadmap("GLaDOS build roadmap")
roadmap.add_milestone(Milestone("Her voice", "Speech in, model, speech out", 1, 10))
roadmap.add_milestone(Milestone("Her mind", "Memory, wake word, rules and habits", 11, 20))
roadmap.add_milestone(Milestone("Her craft", "Delivery, mood, a simulated body", 21, 30))
roadmap.add_milestone(Milestone("One system", "Subsystems composed and validated", 31, 40))
print("Pending:", [m.title for m in roadmap.pending_milestones()])
print(roadmap.progress())
$ uv run python labs/roadmap.py
Pending: ['Her voice', 'Her mind', 'Her craft', 'One system']
0/4 milestones complete (0%)
This stage is deliberately dull. The container, the timestamp stamped only when the
caller supplied none, the one-line status filter: all of it is chapter 26's pattern
copied on purpose, because the interesting part of this chapter is not the plumbing
and a second convention would only make you learn one idea twice. The
if total else 0 guard in progress is there for the first
run of every roadmap, when the list is empty and the percentage would otherwise
divide by zero on line one.
# ... inside Roadmap:
def refresh(self, completed: set[int]) -> list[str]:
newly_done = []
for m in self.pending_milestones():
if m.chapters() <= completed:
m.status = "complete"
newly_done.append(m.title)
return newly_done
finished = set(range(1, 34)) # chapters 1 through 33, actually done
print("Newly complete:", roadmap.refresh(finished))
print(roadmap.progress())
$ uv run python labs/roadmap.py
Newly complete: ['Her voice', 'Her mind', 'Her craft']
3/4 milestones complete (75%)
m.chapters() <= completed is the whole feature. On sets,
<= is not a size comparison but a subset test: it asks whether every
chapter this milestone needs appears in the set of chapters you have finished. Three
milestones flip in one call, nobody ticked a box, and the same call run tomorrow with
one more chapter finished gives a different and equally unargued answer. Compare this
output with the stage-2 run: progress printed 0% there and 75% here, and
the method never changed a character. The data underneath it did.
import json
from dataclasses import asdict
from pathlib import Path
ROADMAP_PATH = Path("glados/data/roadmap.json")
# ... inside Roadmap:
def remaining(self, completed: set[int]) -> list[tuple[str, list[int]]]:
return [(m.title, sorted(m.chapters() - completed))
for m in self.pending_milestones()]
def save_roadmap(roadmap: Roadmap, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
data = {
"name": roadmap.name,
"created_at": roadmap.created_at,
"milestones": [asdict(m) for m in roadmap.milestones],
}
path.write_text(json.dumps(data, indent=2))
for title, left in roadmap.remaining(finished):
print(f" {title}: {len(left)} chapters left, next is {left[0]}")
save_roadmap(roadmap, ROADMAP_PATH)
print("Saved", ROADMAP_PATH)
$ uv run python labs/roadmap.py
One system: 7 chapters left, next is 34
Saved glados/data/roadmap.json
remaining comes free from the representation that made
refresh possible: the same two sets, subtracted instead of compared, and
the difference is a to-do list in chapter order. That is the sentence you actually
want on a Saturday morning, and no human wrote it. save_roadmap is
chapter 26's serializer with the milestone type swapped, and it puts the plan in
glados/data/ next to her memory database, where it can be committed,
diffed, and read by anything else you build.
Why this works: a requirement is a set
The design turns one vague question into two set operations. "Am I done?" becomes
required <= completed. "What is left?" becomes
required - completed. Both read off the same representation, both run in
about the time it takes to hash a few integers, and neither one needs a rule about how
to interpret a row of prose. Once the requirement is enumerable, the answers stop being
opinions.
The pattern is everywhere once you have seen it here. A package manager asking whether your installed versions satisfy a dependency list, an operating system asking whether your process holds every capability a syscall demands, a test runner asking which branches a suite never touched: same subset test, same difference for the report. The price is stated up front. You must be able to list the requirement item by item, so a milestone that says "when the voice sounds good" cannot join in and a milestone that says "chapters 1 through 10" can. Half the work of making a plan computable is agreeing to define done in countable things.
The first version of this file stored the chapter range the way a human writes it, as a label, because a label is what you want to print:
@dataclass
class Milestone:
title: str
chapters: list[str] = field(default_factory=list) # ["C01-C10"]
status: str = "pending"
def refresh(milestones: list[Milestone], completed: set[int]) -> list[str]:
done = []
for m in milestones:
if set(m.chapters) <= completed:
m.status = "complete"
done.append(m.title)
return done
roadmap = [Milestone("Her voice", ["C01-C10"]), Milestone("Her mind", ["C11-C20"])]
print("Newly complete:", refresh(roadmap, set(range(1, 34))))
print("Pending:", [m.title for m in roadmap if m.status == "pending"])
$ uv run python labs/roadmap.py
Newly complete: []
Pending: ['Her voice', 'Her mind']
No exception, no warning, and an answer that is perfectly consistent with a reader who has finished nothing. That is what makes it dangerous: the failure mode of this bug is a plan that agrees you are behind. The symptom says the subset test is false, so print both sides of it before theorizing:
$ uv run python labs/roadmap.py # with both sides printed
required: {'C01-C10'}
completed: [1, 2, 3, 4] ...
One set holds a five-character string, the other holds integers, and no string is ever
equal to a number, so the test was always going to be false for every milestone
forever. Python is right and silent, the way SQLite was silent about comparing
"08" to 8 back in chapter 18. The fix is stage 1: store
first_chapter and last_chapter as integers and let
chapters() build the set, so the label you print is derived from the data
and never mistaken for it.
Checkpoint, with the board on the wall
- I can say why a milestone stores two integers instead of the string
"1-10", and name two questions the integers can answer. - I can read
required <= completedas a subset test and explain why it is not comparing sizes. - I can get the remaining work out of the same two sets that decided completion, with one operator change.
- I can argue for a separate
Roadmapbeside chapter 26's plan on what each record claims, not on how each one is stored. - Shown a completion check that never fires, I look at the elements of both sets before I look at the logic.
Exercise 1 — feed it your real progress. Keep your finished
chapters in glados/data/progress.json as a plain list of numbers, load
them into a set, and run refresh. Which milestone flips, and what does
remaining say you should do next?
completed = set(json.loads(path.read_text())) is the whole loader,
and the set() call is load-bearing: JSON has no set type, so what
comes back is a list, and a list would make every subset test walk the whole
thing. Whatever the answer is, it is now a fact about your repository instead of
a mood, and it changes the day you finish a chapter, without you editing the
roadmap at all.
Exercise 2 — the next thing, not every thing. Add
next_chapter(self, completed: set[int]) -> int | None returning the
lowest unfinished chapter across all pending milestones, and None when
the roadmap is finished. Print it as a one-line "start here" banner.
Union the pending requirements, subtract what is done, and take the minimum:
left = set().union(*(m.chapters() for m in
self.pending_milestones())) - completed, then
return min(left) if left else None. The None branch is
the interesting one; a method that returns 0 or raises
ValueError on an empty roadmap turns the happiest state of the
project into a crash, and min() on an empty set does exactly that
if you let it.
Exercise 3 — make the deliverables count. Extend
refresh so a milestone completes only when its chapters are finished
and every path in deliverables exists on disk. Report the ones
that pass the chapter test but fail the file test.
all(Path(p).exists() for p in m.deliverables) is the second
predicate, and the milestones caught between the two tests are the point of the
exercise. Reading a chapter and building its artifact are different events, and
this is the check that notices when they came apart: you worked through the
automation engine, you never saved the rules file, and the roadmap now says so out
loud instead of congratulating you. Print those titles under a heading like
chapters done, artifact missing and go make the missing file.
Put your own milestones in the file and commit it; the next entries on it leave the desktop. Volume 5 puts real sensors and actuators on a bench, volume 7 gives her a printed body, and every one of those milestones depends on a wire between this machine and a microcontroller that has no operating system, no Python, and no way to tell you it got half a message. The next chapter puts magic bytes, a length, and a checksum in front of everything she sends, so a truncated frame announces itself instead of arriving as plausible nonsense.