GLaDOS Vol 4 · One System
ch 39 / 99
Chapter 39

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.

◆ Note — a goal is a want, a milestone is a claim

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

▣ Build · stage 1 — a milestone that knows its own chapters
# 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.

▣ Build · stage 2 — the container, and the filter you already know
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.

▣ Build · stage 3 — completion, computed
# ... 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.

▣ Build · stage 4 — what is left, and a file to commit
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.

⚠ Worked failure — the roadmap that never finishes anything

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

✓ Checkpoint — what you can now do
  • 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 <= completed as 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 Roadmap beside 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.
⚡ Exercises — try first, then reveal
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.