The Architecture Is a Graph
The drawing and the boot order stop agreeing
Twelve things start when she starts, and they do not start in an arbitrary order. The transcriber is useless until the microphone is open. The brain wants the context engine ready so the first prompt carries memory. The automation engine wants the drivers and the safety filter up before it can be trusted to act, and the conversation loop wants nearly everything. Every one of those sentences is a dependency, and taken together they decide one thing: what is allowed to be constructed first.
Two components that each need the other cannot be ordered at all. Neither can go first, so the boot either hangs waiting or, worse, hands one of them a half-built version of the other and continues as if nothing happened. That defect never announces itself the day it is created. It waits for the morning you restart her in a different order and something reads a value that was not there yet.
The usual defence is a drawing. Boxes, arrows, exported to a PNG that sits in the repo getting quietly wrong, because adding one dependency in code changes nothing in the picture. Even while it is accurate, a drawing is bad at the question you actually have. A person scanning twelve boxes reads a three-hop chain as a straight line and moves on; ask the picture whether the automation engine can safely be started before the brain, and it just sits there. So the rule for the rest of the build: the dependency graph is derived from the registry that boots her, and every claim about that graph comes out of a search over it. One field on the component record, one function that reads the edges back out, and a depth-first search that names the first loop it finds.
A dependency is stored as a string, so "context" rather than the
ComponentConfig object for the context engine. Two reasons. Names let you
register components in any order, including declaring an edge to something further down
the factory that does not exist yet. And names survive a trip through JSON, so the
graph the program checks is the graph the config file states, with no reconstruction
step in between. The cost is that a misspelled name is not an error, only a name
nothing answers to, and stage 3 is where you buy that back.
One field, one adjacency list, one search
# labs/system_config.py — one new field, and a factory that registers all of her
@dataclass
class ComponentConfig:
name: str
enabled: bool = True
settings: dict = field(default_factory=dict)
depends_on: list[str] = field(default_factory=list)
def build_default_config() -> SystemConfig:
cfg = SystemConfig()
cfg.add_component(ComponentConfig("audio_capture", True, {
"sample_rate": 16000,
"channels": 1,
"chunk": 1024,
}))
cfg.add_component(ComponentConfig("stt", True, {
"model": "base",
"device": "cpu",
"compute_type": "int8",
"sample_rate": 16000,
}, depends_on=["audio_capture"]))
cfg.add_component(ComponentConfig("wake_word", True, {
"phrase": "glados",
"threshold": 0.6,
}, depends_on=["audio_capture"]))
cfg.add_component(ComponentConfig("tts", True, {
"voice": "en_US-lessac-medium",
"sample_rate": 22050,
}))
cfg.add_component(ComponentConfig("memory", True, {
"db_path": "glados/data/memory.db",
}))
cfg.add_component(ComponentConfig("context", True, {
"turns": 6,
"facts": 4,
}, depends_on=["memory"]))
cfg.add_component(ComponentConfig("llm", True, {
"model": "llama3.2:3b",
"host": "http://localhost:11434",
}, depends_on=["context"]))
cfg.add_component(ComponentConfig("safety", True, {
"policy": "configs/safety.json",
}))
cfg.add_component(ComponentConfig("drivers", True, {
"port": "/dev/ttyUSB0",
"baud": 115200,
}))
cfg.add_component(ComponentConfig("automation", True, {
"rules": "configs/automation.json",
}, depends_on=["drivers", "safety"]))
cfg.add_component(ComponentConfig("scheduler", True, {
"tick_seconds": 30,
}, depends_on=["automation"]))
cfg.add_component(ComponentConfig("voice_loop", True, {
"max_turns": 0,
}, depends_on=["wake_word", "stt", "llm", "tts", "safety"]))
return cfg
# labs/system_config.py — the edges have to survive the trip to disk and back
data = { # in save_config
"version": cfg.version,
"components": [
{"name": c.name, "enabled": c.enabled, "settings": c.settings,
"depends_on": c.depends_on}
for c in cfg.components
],
}
for c in data["components"]: # in load_config
cfg.add_component(ComponentConfig(c["name"], c.get("enabled", True),
c.get("settings", {}),
c.get("depends_on", [])))
# labs/arch_graph.py
from labs.system_config import SystemConfig, build_default_config
def build_graph(cfg: SystemConfig) -> dict[str, list[str]]:
return {c.name: list(c.depends_on) for c in cfg.components}
def main() -> None:
graph = build_graph(build_default_config())
for name, deps in graph.items():
print(f" {name:<14} -> {', '.join(deps) or '(none)'}")
if __name__ == "__main__":
main()
$ uv run python -m labs.arch_graph
audio_capture -> (none)
stt -> audio_capture
wake_word -> audio_capture
tts -> (none)
memory -> (none)
context -> memory
llm -> context
safety -> (none)
drivers -> (none)
automation -> drivers, safety
scheduler -> automation
voice_loop -> wake_word, stt, llm, tts, safety
The field goes on the component record, not into a new architecture file, because a
second file listing dependencies would be the drawing again with Python syntax.
Growing the factory is the same argument one step further. Chapter 31 registered the
four subsystems that existed at the time; capture, wake word detection, context
assembly, the safety filter, the device drivers, automation, the scheduler and the
conversation loop have all been built since, and none of them had an entry until now.
Writing them in gives every moving part one place to be named, with its edges standing
beside its settings instead of in a second document. Two more lines carry those edges
through the file: "depends_on": c.depends_on in the dict
save_config builds, and c.get("depends_on", []) in
load_config so a saved registry comes back with its graph intact. What
comes back from build_graph is an adjacency list: every node
mapped to the nodes it points at. That is the format every graph algorithm in this
chapter expects, and getting the data into it is most of the work. The
list(...) call copies each dependency list, so nothing the search does can
reach back into the registry and edit it, the aliasing trap from chapter 31.
def find_cycle(graph: dict[str, list[str]]) -> list[str] | None:
visited: set[str] = set()
stack: set[str] = set()
def walk(node: str, path: list[str]) -> list[str] | None:
visited.add(node)
stack.add(node)
for dep in graph.get(node, []):
if dep in stack:
return path + [dep]
if dep not in visited:
found = walk(dep, path + [dep])
if found is not None:
return found
stack.discard(node)
return None
for node in graph:
if node not in visited:
found = walk(node, [node])
if found is not None:
return found
return None
if __name__ == "__main__":
graph = {
"memory": [],
"context": ["memory", "automation"],
"llm": ["context"],
"automation": ["drivers", "llm"],
"drivers": [],
}
cycle = find_cycle(graph)
print(" -> ".join(cycle) if cycle else "no cycle")
$ uv run python -m labs.arch_graph
context -> automation -> llm -> context
Five components, four of the five lines look reasonable on their own, and the loop runs
through three of them. Read the dict and try to spot it before reading the output; that
difficulty is the argument for the whole chapter. Two sets do the work.
visited grows forever and stops the search re-exploring a node it already
finished. stack holds only the nodes on the path currently open beneath
you, so it grows on the way down and shrinks on the way back up. Meeting a node that is
still in stack means you have walked in a circle back to something above
you. The function returns the path instead of a boolean so the message can name the
loop, because "a cycle exists" sends you back to the drawing you were trying to escape.
def undefined_refs(graph: dict[str, list[str]]) -> list[tuple[str, str]]:
known = set(graph)
return sorted(
(name, dep)
for name, deps in graph.items()
for dep in deps
if dep not in known
)
if __name__ == "__main__":
graph = {
"audio_capture": [],
"wake_word": ["audio_capture"],
"voice_loop": ["wake-word", "stt"],
}
for name, dep in undefined_refs(graph):
print(f"{name} declares {dep!r}, which no component provides")
$ uv run python -m labs.arch_graph
voice_loop declares 'stt', which no component provides
voice_loop declares 'wake-word', which no component provides
Look at what graph.get(node, []) does in stage 2 when a name is unknown:
it returns an empty list, and the search treats the name as a component with no
dependencies of its own. Convenient while you are building, silent forever after. Both
lines here are that silence made visible, and they are different bugs. The
stt entry points at something real that nobody registered, so the check is
telling you the registry is incomplete. The wake-word entry is a hyphen
where the registry uses an underscore, and the cycle detector would never have said a
word about it. {dep!r} prints the string with its quotes, which is what
makes a stray character visible instead of ambiguous.
# labs/arch_graph.py — full file
import json
from pathlib import Path
from labs.system_config import SystemConfig, build_default_config
REPORT_PATH = Path("docs/architecture.json")
def build_graph(cfg: SystemConfig) -> dict[str, list[str]]:
return {c.name: list(c.depends_on) for c in cfg.components}
def find_cycle(graph: dict[str, list[str]]) -> list[str] | None:
visited: set[str] = set()
stack: set[str] = set()
def walk(node: str, path: list[str]) -> list[str] | None:
visited.add(node)
stack.add(node)
for dep in graph.get(node, []):
if dep in stack:
return path + [dep]
if dep not in visited:
found = walk(dep, path + [dep])
if found is not None:
return found
stack.discard(node)
return None
for node in graph:
if node not in visited:
found = walk(node, [node])
if found is not None:
return found
return None
def undefined_refs(graph: dict[str, list[str]]) -> list[tuple[str, str]]:
known = set(graph)
return sorted(
(name, dep)
for name, deps in graph.items()
for dep in deps
if dep not in known
)
def render(graph: dict[str, list[str]], cycle: list[str] | None,
missing: list[tuple[str, str]]) -> str:
edges = sum(len(deps) for deps in graph.values())
lines = [f"GLaDOS architecture: {len(graph)} components, {edges} edges", ""]
for name, deps in graph.items():
lines.append(f" {name:<14} -> {', '.join(deps) or '(none)'}")
lines.append("")
for name, dep in missing:
lines.append(f" [FAIL] {name} declares {dep!r}, which no component provides")
if cycle is not None:
lines.append(" [FAIL] cycle: " + " -> ".join(cycle))
if cycle is None and not missing:
lines.append(" [OK] no cycles, every dependency resolves")
return "\n".join(lines)
def main() -> None:
graph = build_graph(build_default_config())
cycle = find_cycle(graph)
missing = undefined_refs(graph)
print(render(graph, cycle, missing))
status = "PASS" if cycle is None and not missing else "FAIL"
REPORT_PATH.parent.mkdir(parents=True, exist_ok=True)
REPORT_PATH.write_text(json.dumps({
"status": status,
"graph": graph,
"cycle": cycle,
"undefined": [list(m) for m in missing],
}, indent=2))
print(f"Wrote {REPORT_PATH} (status {status})")
if __name__ == "__main__":
main()
$ uv run python -m labs.arch_graph
GLaDOS architecture: 12 components, 12 edges
audio_capture -> (none)
stt -> audio_capture
wake_word -> audio_capture
tts -> (none)
memory -> (none)
context -> memory
llm -> context
safety -> (none)
drivers -> (none)
automation -> drivers, safety
scheduler -> automation
voice_loop -> wake_word, stt, llm, tts, safety
[OK] no cycles, every dependency resolves
Wrote docs/architecture.json (status PASS)
Now give the automation engine permission to ask the brain for wording, so an alert
comes out in her voice, and let the context engine mention routines that are due. Two
small, defensible additions: automation gains "llm" and
context gains "automation". Neither author is doing anything
wrong. The loop appears between them.
$ uv run python -m labs.arch_graph | tail -2
[FAIL] cycle: context -> automation -> llm -> context
Wrote docs/architecture.json (status FAIL)
The JSON file is the point of the exercise as much as the printout. Commit it, and the
next time somebody changes a dependency, the diff shows the architecture moving:
one line changed, status flipped, in the same review as the code that caused it. The
fix for this particular loop is the standard one. Find what the two components really
share, give it to a third thing that depends on nothing, and let both point at it. The
automation engine does not need the brain; it needs a phrasing function. Register that
as its own component with an empty depends_on and the cycle dissolves.
Why this works: a back edge, and the invariant that identifies one
Depth-first search walks down one path as far as it goes before trying anything else, and every edge it crosses falls into one of two categories that matter here. An edge to a node that is finished, reached earlier by some other route, is ordinary. An edge to a node that is still open, still waiting for the walk beneath it to come back, is a back edge, and a back edge is exactly a path from a node to one of its own ancestors. That is what a cycle is, stated in a way a program can test.
The invariant is one sentence, and the correctness of the detector is nothing more than
keeping it true: a node is in stack for precisely as long as its own walk is
unfinished. Added on entry, removed by stack.discard(node) the moment its
last dependency has been explored. So visited answers "have I been here?"
and stack answers "am I still inside here?", and only the second question
detects a loop. A detector with just the first set cannot distinguish the transcriber and
the wake word detector both depending on audio capture, which is fine, from a genuine
circle, which is fatal. It would flag both, and a check that flags correct designs gets
switched off within a week.
The discard line looks like housekeeping, so it is the line that gets
dropped. The code still runs. It simply starts inventing loops, and the first design it
accuses is the most common legal one in the whole architecture:
def walk(node: str, path: list[str]) -> list[str] | None:
visited.add(node)
stack.add(node)
for dep in graph.get(node, []):
if dep in stack:
return path + [dep]
if dep not in visited:
found = walk(dep, path + [dep])
if found is not None:
return found
return None # BUG: node never leaves the stack
if __name__ == "__main__":
graph = {
"voice_loop": ["stt", "wake_word"],
"stt": ["audio_capture"],
"wake_word": ["audio_capture"],
"audio_capture": [],
}
cycle = find_cycle(graph)
print(" -> ".join(cycle) if cycle else "no cycle")
$ uv run python -m labs.arch_graph
voice_loop -> wake_word -> audio_capture
There is no cycle in that dict. Three components and four edges, and the only unusual
thing is that two of them lead to audio_capture. Read the reported path
closely and it convicts itself: a real cycle ends where it started, and this one ends
somewhere new. That tell is worth remembering, because it separates a broken detector
from a broken architecture in about two seconds. The cause: the first branch walked
stt and then audio_capture, and neither was ever removed from
stack. When the second branch reached audio_capture through
wake_word, it found it sitting in stack and concluded it had
found an ancestor. Restore the discard and the same graph reports no cycle,
because by the time the second branch arrives, the first branch has finished and left.
Checkpoint, and the boxes between the boxes
- I can turn a list of registered components into an adjacency list, and say what each key and each list element means.
- I can define a back edge, and state the invariant that
stackmaintains in one sentence. - I can explain why
visitedon its own reports a false loop whenever two components share a dependency. - Handed a reported cycle, I can tell a genuine one from a detector bug by checking whether the path returns to its first node.
- I know why a misspelled dependency is invisible to the cycle search, and which of the two checks catches it.
- Given a two-component loop, I can name the standard repair: extract what both need into a component that depends on nothing.
Exercise 1 — print the startup order. Write
init_order(graph) that returns a list in which every component appears
after all of its dependencies, then print it numbered. An acyclic graph always has at
least one such order.
Count how many dependencies each component is still waiting on, start with the ones
waiting on zero, and every time you place a component, decrement the count of
everything that depended on it. A collections.deque holds the ready
set; pop from the left, append when a count reaches zero. Two details make it
useful. Feed the ready set in sorted order and the output is stable between runs,
so it can be committed next to the JSON report. And if the returned list is shorter
than the graph, the components missing from it are exactly the ones tangled in a
cycle, which makes this a second, independent cycle detector. Printing
1. audio_capture through 12. voice_loop gives you the
order the core should construct its parts in.
Exercise 2 — the disabled dependency. The registry has an
enabled flag. Write a check that reports any enabled component whose
dependency list names a component that is switched off, then turn
tts off and run it.
Build a second dict, {c.name: c.enabled for c in cfg.components}, and
walk the same edges the graph walked, reporting a pair when the source is enabled
and the target is not. Switching tts off produces
voice_loop is enabled but depends on tts, which is disabled. This is a
different failure class from the two in the chapter: the graph is acyclic, every
name resolves, and she still cannot boot the conversation loop. Note that the
cycle detector deliberately keeps disabled components in the graph, since a
component you switched off for the afternoon is still part of the architecture.
Exercise 3 — what does this change break? Write
impact_of(graph, target) returning every component that depends on
target directly or indirectly, and run it for memory.
Walk the edges backwards. Keep a set of affected names and a stack seeded with the
target; pop a name, scan the graph for any component listing it as a dependency, and
push each new one. For memory the answer is
['context', 'llm', 'voice_loop'], which reads as a sentence: changing
how facts are stored can change what she says, through two hops you might not have
predicted. Print that set before you start editing a component and you have a
retest list generated from the architecture instead of from memory.
The architecture now checks itself, and what it proves is a property of the boxes and the arrows between them: nothing circular, no dangling name, a legal order to start in. What no graph can tell you is whether the transcriber's output is actually in a form the brain accepts, or whether a context block she assembled truly reaches the prompt. Those bugs live inside the arrows, where one component's output becomes another's input, and the next chapter goes looking for them with assertions that assume failure until proven otherwise.