GLaDOS Vol 3 · Her Craft
ch 26 / 99
Chapter 26

A Plan You Can Query

The project outgrew your memory

Somewhere around the mood system, this project stopped fitting in your head. She has a voice pipeline, a memory database, preferences, a lab notebook, and a personality file, and volume 4 is about to add motors. On a project this size you drift into working reactively: you patch whatever broke today instead of advancing the piece that matters most. The question you need answered every time you sit down is "what should I work on next, and is anything overdue?" — and you want a machine, not your recollection, to answer it.

The obvious answer is a notes file: a Markdown checklist, a list of strings. It reads fine, and it is opaque to code. You cannot ask a paragraph for its top three active items, or for every deadline that has passed, without parsing prose by hand; the moment you want to sort or filter, a text blob sends you back to re-reading, and re-reading is exactly the manual work you were trying to delete. Chapter 25 made this argument for experiments; goals deserve the same treatment.

So the rule this chapter adds to the project: if you might ever want to filter, sort, or count it, store it as fields, not prose. A goal becomes a Goal dataclass with a priority, a status, and an optional deadline; a StrategicPlan holds the list and answers questions about it; JSON on disk makes the plan survive a restart.

◆ Note — deadlines as ISO 8601 strings

Deadlines in this chapter are strings like "2026-11-01", not date objects. JSON has no date type, so a string was always going to appear at the disk boundary; storing the ISO form end to end means no conversion layer. And ISO 8601 puts the largest unit first, zero-padded, so the alphabetical order of the strings equals the chronological order of the dates: sorted() on raw strings puts the nearest deadline first with no parsing at all. When real date arithmetic is needed, date.fromisoformat() is one call away; exercise 3 uses it.

Model, contain, persist

▣ Build · stage 1 — one goal as data
# labs/strategic_plan.py
from dataclasses import dataclass, field

@dataclass
class Goal:
    title: str
    description: str
    priority: int = 1
    deadline: str | None = None
    status: str = "active"
    subtasks: list[str] = field(default_factory=list)

goal = Goal(
    "The body",
    "Servo control, simulated first, then real",
    priority=1,
    subtasks=["GPIO simulator", "servo sweep", "safety limits"],
)
print(f"{goal.title}: {goal.status}, {len(goal.subtasks)} subtasks")
$ uv run python labs/strategic_plan.py
The body: active, 3 subtasks

The same tool that gave chapter 2 its VoiceConfig, doing the same job: a dict has no schema, so a typo like "titel" creates a silent second key, while a dataclass names every field, rejects unknowns, and hands you __init__ and __repr__ for free. The defaults encode policy (a new goal is active, priority 1, no deadline), and the one default that is not a plain value, field(default_factory=list), exists because a bare = [] would be shared by every instance. The failure box gives that trap its full run.

▣ Build · stage 2 — a container that answers questions
from datetime import datetime

@dataclass
class StrategicPlan:
    name: str
    goals: list[Goal] = 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_goal(self, goal: Goal) -> None:
        self.goals.append(goal)

    def complete_goal(self, title: str) -> bool:
        for goal in self.goals:
            if goal.title == title:
                goal.status = "complete"
                return True
        return False

    def active_goals(self) -> list[Goal]:
        return [g for g in self.goals if g.status == "active"]

plan = StrategicPlan("GLaDOS: her craft")
plan.add_goal(Goal("The body", "Servo control on a simulator", priority=1))
plan.add_goal(Goal("Latency audit", "Time every pipeline stage", priority=2))
print("Active:", [g.title for g in plan.active_goals()])
print("Completed:", plan.complete_goal("Latency audit"))
print("Active now:", [g.title for g in plan.active_goals()])
$ uv run python labs/strategic_plan.py
Active: ['The body', 'Latency audit']
Completed: True
Active now: ['The body']

Because the data is structured, every operation collapses to almost nothing: active_goals is a one-line comprehension, complete_goal flips one field. Notice what complete_goal returns. The match is an exact string comparison, so "latency audit" in lowercase would match nothing, and a method returning None would let that miss pass in silence; returning bool makes the caller see False and ask why. __post_init__ stamps a creation time only when none was supplied, so a fresh plan self-dates while a plan loaded from disk keeps its original timestamp.

▣ Build · stage 3 — persist, mutate, and prove it landed
import json
from dataclasses import asdict
from pathlib import Path

PLAN_PATH = Path("glados/data/strategic_plan.json")

def save_plan(plan: StrategicPlan, path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    data = {
        "name": plan.name,
        "created_at": plan.created_at,
        "goals": [asdict(g) for g in plan.goals],
    }
    path.write_text(json.dumps(data, indent=2))

save_plan(plan, PLAN_PATH)
plan.complete_goal("The body")
save_plan(plan, PLAN_PATH)
on_disk = json.loads(PLAN_PATH.read_text())
print("On disk:", on_disk["goals"][0]["title"], "is", on_disk["goals"][0]["status"])
$ uv run python labs/strategic_plan.py
On disk: The body is complete

The save happens twice on purpose. The plan in memory and the plan on disk are two separate things: complete_goal changes only the object, and until the second save_plan the file still says "active". The final read-back is the proof, and it deliberately goes through the file, not the object; printing plan.goals[0].status would only prove the mutation, while reading the JSON proves the mutation arrived. It is the same cheap round-trip test that chapter 4 ran on a WAV file, applied to state instead of audio.

Why this works: rich objects inside, plain data at the edge

save_plan is small, but it sits on a real boundary. Inside the program you want rich objects: methods, defaults, type hints, a complete_goal that returns whether it matched. At the edge of the program (a file, a socket, an API) you want the dumbest data you can get away with, because JSON understands exactly six things: objects, arrays, strings, numbers, booleans, and null. Hand json.dump a Goal instance and it raises TypeError: Object of type Goal is not JSON serializable; hand it the output of asdict(), which recursively converts a dataclass and everything nested inside it into plain dicts and lists, and it writes without complaint.

The pattern generalizes well past this file. Her personality config, her preferences from chapter 22, this plan: each one is a dataclass or dict in memory and plain JSON at rest, converted at one named function on the way out and one on the way in. Keep the conversion in one place and the file format has a single owner; scatter asdict() calls through the codebase and every caller becomes a place the format can drift. The same instinct has save_plan build its top-level dict by hand: asdict(plan) would work too, but the explicit version shows the file's exact structure in the one function that owns it.

⚠ Worked failure — the class that refused to define

Every goal needs its own subtasks list, so you give the field an empty-list default the way you would in any other class:

@dataclass
class Goal:
    title: str
    description: str
    subtasks: list[str] = []   # looks harmless
$ uv run python labs/strategic_plan.py
Traceback (most recent call last):
  File "labs/strategic_plan.py", line 4, in <module>
    @dataclass
     ^^^^^^^^^
ValueError: mutable default <class 'list'> for field subtasks is not allowed: use default_factory

Read where it fired: at the @dataclass line, at import time, before a single Goal existed. Python evaluates a default value once, when the class is defined, so a bare [] would become one list object shared by every instance; append a subtask to one goal and every goal grows it. In an ordinary class that bug ships and surfaces weeks later as crosstalk between objects. Dataclasses check for the mutable default up front and refuse to build the class at all, which is the friendliest failure in this book so far: the error names the field, names the fix (default_factory), and arrives before any data exists to corrupt. default_factory=list calls list() fresh per instance, and the class defines.

Checkpoint, with the plan on disk

✓ Checkpoint — what you can now do
  • I can say what a notes file cannot answer that a StrategicPlan can, and point to the field that makes each query possible.
  • I can explain why subtasks needs default_factory=list, when the bare-list bug would fire, and why dataclasses reject it at class definition instead.
  • I know why complete_goal returns bool, and the silent miss the exact-title match would otherwise hide.
  • I can name the two representations a goal has (dataclass in memory, dict in JSON) and the function that converts between them.
  • I can prove a state change reached the disk by reading the file back, and I know why checking the object proves less.
⚡ Exercises — try first, then reveal
Exercise 1 — close the round trip. Write load_plan(path: Path) -> StrategicPlan so the plan survives a restart, then prove that saving, loading, and comparing titles and subtasks loses nothing.

Rebuild each goal with Goal(**g), since asdict produced dicts whose keys match the field names exactly, then pass created_at back into the constructor: StrategicPlan(name=data["name"], goals=[Goal(**g) for g in data["goals"]], created_at=data["created_at"]). Because __post_init__ only stamps a time when the field is empty, the loaded plan keeps its original timestamp; drop the argument and every load would quietly re-date the plan to now. Print the loaded subtasks next to the originals to see the round trip hold.

Exercise 2 — what next, by the numbers. Add goals_by_priority(self) -> list[Goal] returning active goals sorted ascending, so the plan itself answers the chapter's opening question. Three goals with priorities 3, 1, 2 should come back in order 1, 2, 3.

Filter first, then sort: sorted(self.active_goals(), key=lambda g: g.priority). Reusing active_goals() keeps one definition of "active" in the class, so a future status like "blocked" changes one method, not two. With goals added at priorities 3, 1, and 2, the printed titles come back lowest-number first; "what should I work on next" is now plan.goals_by_priority()[0].title, a query instead of a re-reading.

Exercise 3 — what's overdue. Add overdue_goals(self, today: date | None = None) -> list[Goal] returning active goals whose deadline has passed. Test it with one past deadline, one future deadline, and one goal with no deadline at all.

Guard first, compare second: skip goals unless g.status == "active" and g.deadline, then keep those where date.fromisoformat(g.deadline) < today. The guard matters because fromisoformat(None) raises TypeError, and a deadline-free goal is a legitimate state, not an error. The today parameter is chapter 25's discipline applied to time: pass a fixed date in a test and the result repeats whenever you run it, while the None default keeps real calls on the real calendar.

Open glados/data/strategic_plan.json and put your real goals in it, because the plan is about to get its biggest entry. The next stretch of work is her body: motors, sensors, a machine she can move. You do not own that hardware yet, and you do not need it to start: the next chapter builds a GPIO simulator faithful enough that every control path gets written and tested on your desk, before the first real servo ever draws current.