GLaDOS Vol 6 · Ready for the House
ch 57 / 99
Chapter 57

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.

◆ Note — dependencies are names, not object references

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.

Seven components, eight dependency edges, one of them a back edge The conversation loop depends on the transcriber, the wake word detector and the brain. The transcriber and the wake word detector both depend on audio capture, which is two paths to one node and perfectly legal. The brain depends on the context engine, the context engine on the automation engine, and the automation engine back on the brain: a three-hop loop that no ordering can satisfy. ARROW = DEPENDS ON · RED = THE LOOP DFS REPORTS voice_loop stt audio_capture wake_word llm context automation back edge two routes to audio_capture, and no loop
Figure 57.1 — Eight edges, all of them declared by components rather than drawn by hand. Three close a loop; two reach one node by different routes and are entirely legal. Telling those two situations apart is the whole job of the detector.

One field, one adjacency list, one search

▣ Build · stage 1 — declare the edges, then read them back out
# 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.

▣ Build · stage 2 — depth-first search, with a stack that shrinks
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.

▣ Build · stage 3 — the dependency that nothing provides
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.

▣ Build · stage 4 — the review, and an artifact you can commit
# 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.

⚠ Worked failure — a cycle report with no cycle in it

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

✓ Checkpoint — what you can now do
  • 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 stack maintains in one sentence.
  • I can explain why visited on 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.
⚡ Exercises — try first, then reveal
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.