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.
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
# 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.
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.
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.
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
- I can say what a notes file cannot answer that a
StrategicPlancan, and point to the field that makes each query possible. - I can explain why
subtasksneedsdefault_factory=list, when the bare-list bug would fire, and why dataclasses reject it at class definition instead. - I know why
complete_goalreturnsbool, 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.
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.