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

The Component Registry

Thirty scripts in one folder

Three volumes in, a great deal works. She captures your microphone and proves the buffer was not silent, transcribes it locally, thinks with a model you pulled once, answers in her own voice, remembers what you told her last week, notices habits, fires on a schedule, and speaks a framed protocol over a socket. Every one of those scripts runs. Run them together and you meet the problem this volume exists for: nothing anywhere states what she is made of. There is no file you can open, and no object you can print, that answers "what is she running right now?"

The symptom is duplicated facts. The string "base" sits at the top of the transcription module, "llama3.2:3b" at the top of the brain module, "en_US-lessac-medium" inside the call that speaks, and "glados/data/memory.db" in the memory module and again in the rated interaction log, because both open the same database and each learned the path separately. Nothing is wrong while all four agree. Then you try to run her on a machine with no GPU, or swap in a smaller model for an afternoon of testing, and the edit is not one line in one place: it is a search across the project, and the two copies you miss keep working with the old value until something downstream reads the wrong one.

So the rule this volume opens with: configuration is data. One typed object describes every subsystem, and each module reads its settings from that object instead of owning a private copy. Today you build the object: a registry of components, each with a name, an on/off flag, and its own settings. The chapters after it use the registry to assemble a core whose parts are handed in from outside, probe every one of those parts at startup so a broken engine fails at second zero, and validate the numbers before a microphone ever opens. By the end of the volume the pile is a system, and that system speaks to a microcontroller over a wire.

◆ Note — the registry holds paths, not payloads

Her personality already lives in configs/personality.json, and it stays there. The registry is not a place to paste that file's contents; it is where the location of that file is written down, once, so the module that loads it stops carrying a literal path. Same for the database and the voice model. Keep settings small and referential and the registry stays readable at a glance, which is the only property that makes it worth having.

One object per subsystem, one registry over all of them

▣ Build · stage 1 — describe a single subsystem
# labs/system_config.py
from dataclasses import dataclass, field


@dataclass
class ComponentConfig:
    name: str
    enabled: bool = True
    settings: dict = field(default_factory=dict)


if __name__ == "__main__":
    stt = ComponentConfig("stt", True, {"model": "base", "device": "cpu"})
    print(stt)
    print(stt.settings["model"])
$ uv run python labs/system_config.py
ComponentConfig(name='stt', enabled=True, settings={'model': 'base', 'device': 'cpu'})
base

Three fields, because there are three questions you ask of any subsystem: what is it called, is it switched on, and how is it configured. enabled earns its place as a real field so that turning transcription off is a value change rather than a block of commented-out code. settings stays a plain dict on purpose: the knobs differ wildly between a speech recognizer and a database, and a dict absorbs that variety without a separate class per subsystem. The dataclass move is the one from chapter 2, applied a level up: back then it grouped one voice's sample rate, model name and volume, now it groups a whole subsystem. Note field(default_factory=dict), the form chapter 25 arrived at the hard way when the decorator refused to build a class with a bare {} default.

▣ Build · stage 2 — a registry, and a lookup that cannot crash
@dataclass
class SystemConfig:
    version: str = "1.0"
    components: list[ComponentConfig] = field(default_factory=list)

    def add_component(self, comp: ComponentConfig) -> None:
        self.components.append(comp)

    def get_component(self, name: str) -> ComponentConfig | None:
        return next((c for c in self.components if c.name == name), None)


if __name__ == "__main__":
    cfg = SystemConfig()
    cfg.add_component(ComponentConfig("stt", True, {"model": "base"}))
    cfg.add_component(ComponentConfig("llm", True, {"model": "llama3.2:3b"}))
    print(cfg.get_component("llm"))
    print(cfg.get_component("vision"))
$ uv run python labs/system_config.py
ComponentConfig(name='llm', enabled=True, settings={'model': 'llama3.2:3b'})
None

next() pulls the first item out of the generator expression, and its second argument is what it hands back when the generator produces nothing at all. Drop that argument and a missing name raises StopIteration from inside a one-line method, several frames away from the caller who mistyped "llm". Keep it and absence becomes an ordinary value you can test with is None. That matters more than it looks: "vision" is not a typo here, it is a component that honestly does not exist yet, and asking about an unconfigured subsystem should get a calm answer. The return annotation ComponentConfig | None tells every caller, and every type checker, that the guard is required.

▣ Build · stage 3 — one function that declares the whole system
def build_default_config() -> SystemConfig:
    cfg = SystemConfig()
    cfg.add_component(ComponentConfig("stt", True, {
        "model": "base",
        "device": "cpu",
        "compute_type": "int8",
        "sample_rate": 16000,
    }))
    cfg.add_component(ComponentConfig("llm", True, {
        "model": "llama3.2:3b",
        "host": "http://localhost:11434",
    }))
    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",
    }))
    return cfg


if __name__ == "__main__":
    for comp in build_default_config().components:
        flag = "+" if comp.enabled else "-"
        print(f"  [{flag}] {comp.name}: {comp.settings}")
$ uv run python labs/system_config.py
  [+] stt: {'model': 'base', 'device': 'cpu', 'compute_type': 'int8', 'sample_rate': 16000}
  [+] llm: {'model': 'llama3.2:3b', 'host': 'http://localhost:11434'}
  [+] tts: {'voice': 'en_US-lessac-medium', 'sample_rate': 22050}
  [+] memory: {'db_path': 'glados/data/memory.db'}

Four lines of output, and for the first time they are the answer to "what is she running?" Read them and notice something the scattered version hid: the two sample rates differ, 16,000 where she listens and 22,050 where she speaks, because the transcription model was trained at one rate and the voice model synthesizes at the other. Correct, deliberate, and invisible while those numbers lived in different files. This factory is also the seam where profiles appear. A second function that returns a SystemConfig with "device": "cuda" and a bigger transcription model is a full GPU profile, and it needs no new class and no new field: the dataclasses fix the structure, the factories choose the values.

▣ Build · stage 4 — write it to disk, read it back, and the assembled module
# labs/system_config.py — full file
import json
from dataclasses import dataclass, field
from pathlib import Path

CONFIG_PATH = Path("configs/system_config.json")


@dataclass
class ComponentConfig:
    name: str
    enabled: bool = True
    settings: dict = field(default_factory=dict)


@dataclass
class SystemConfig:
    version: str = "1.0"
    components: list[ComponentConfig] = field(default_factory=list)

    def add_component(self, comp: ComponentConfig) -> None:
        self.components.append(comp)

    def get_component(self, name: str) -> ComponentConfig | None:
        return next((c for c in self.components if c.name == name), None)


def build_default_config() -> SystemConfig:
    cfg = SystemConfig()
    cfg.add_component(ComponentConfig("stt", True, {
        "model": "base",
        "device": "cpu",
        "compute_type": "int8",
        "sample_rate": 16000,
    }))
    cfg.add_component(ComponentConfig("llm", True, {
        "model": "llama3.2:3b",
        "host": "http://localhost:11434",
    }))
    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",
    }))
    return cfg


def save_config(cfg: SystemConfig, path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    data = {
        "version": cfg.version,
        "components": [
            {"name": c.name, "enabled": c.enabled, "settings": c.settings}
            for c in cfg.components
        ],
    }
    path.write_text(json.dumps(data, indent=2))


def load_config(path: Path) -> SystemConfig:
    data = json.loads(path.read_text())
    cfg = SystemConfig(version=data.get("version", "1.0"))
    for c in data["components"]:
        cfg.add_component(ComponentConfig(c["name"], c.get("enabled", True),
                                          c.get("settings", {})))
    return cfg


def main() -> None:
    cfg = build_default_config()
    for comp in cfg.components:
        flag = "+" if comp.enabled else "-"
        print(f"  [{flag}] {comp.name}: {comp.settings}")
    save_config(cfg, CONFIG_PATH)
    print(f"Wrote {CONFIG_PATH} (version {cfg.version}, {len(cfg.components)} components)")
    reloaded = load_config(CONFIG_PATH)
    print(f"Read it back: {len(reloaded.components)} components, "
          f"llm model {reloaded.get_component('llm').settings['model']}")


if __name__ == "__main__":
    main()
$ uv run python labs/system_config.py
  [+] stt: {'model': 'base', 'device': 'cpu', 'compute_type': 'int8', 'sample_rate': 16000}
  [+] llm: {'model': 'llama3.2:3b', 'host': 'http://localhost:11434'}
  [+] tts: {'voice': 'en_US-lessac-medium', 'sample_rate': 22050}
  [+] memory: {'db_path': 'glados/data/memory.db'}
Wrote configs/system_config.json (version 1.0, 4 components)
Read it back: 4 components, llm model llama3.2:3b
$ head -9 configs/system_config.json
{
  "version": "1.0",
  "components": [
    {
      "name": "stt",
      "enabled": true,
      "settings": {
        "model": "base",
        "device": "cpu",

json.dumps knows dicts, lists, strings, numbers and booleans, and nothing else; hand it a ComponentConfig and it raises TypeError: Object of type ComponentConfig is not JSON serializable. So save_config flattens each component into a plain dict first. The asdict() helper from chapter 25 would do that conversion in one call, and it is the right tool when the object is the file format. Here the comprehension names the three keys on purpose: the on-disk format is a promise to every future reader of this file, and spelling it out means adding a field to the dataclass tomorrow cannot silently change what lands on disk. mkdir(parents= True, exist_ok=True) creates configs/ if it is missing and says nothing if it already exists, so the script is safe to run twice, or fifty times.

load_config is the other half of that promise, and it belongs in the module beside save_config for a plain reason: a format nothing can read back is a format you cannot trust. It rebuilds real ComponentConfig objects from the dicts, so what returns has get_component and a printable repr instead of nested keys. The two .get calls are deliberate. A file written by an earlier revision of this module can be missing a field today's dataclass has, and reading one should hand you the default rather than a KeyError from inside a loop. The last line of main proves the round trip on every run.

Why this works: a printable manifest, and what assignment really copies

A dict keyed by component name would have made get_component a one-liner with no generator in sight. The list of typed objects buys three things instead. Order survives, so the printout always reads listen, think, speak, remember, in the order the pipeline runs. It maps cleanly onto a JSON array, which is what a human-editable config file wants to be. And every component is an object with a generated __repr__ and __eq__, so printing one shows every field and comparing two says whether they describe the same thing. Nested plain dicts give you the same data with none of that, and turn a typo into a KeyError at the worst possible moment, mid-conversation, instead of a None you can check.

The scan is linear, and at four components that is beneath measurement. The reason to hide it behind a method anyway is the day there are forty: you rewrite the body of get_component to consult a dict index and not one caller changes. Lookup is a decision the registry gets to keep to itself. That is the same instinct as the dispatch registry in chapter 17, one level higher up: the callers ask by name, and the structure behind the name stays private.

⚠ Worked failure — the setting that changed two components

Two components share most of their settings, so you factor the common part out. This looks like ordinary good practice:

ACCEL = {"device": "cpu", "compute_type": "int8"}

cfg = SystemConfig()
cfg.add_component(ComponentConfig("stt", True, ACCEL))
cfg.add_component(ComponentConfig("vision", False, ACCEL))

cfg.get_component("stt").settings["device"] = "cuda"   # GPU for transcription only
print("stt   ", cfg.get_component("stt").settings)
print("vision", cfg.get_component("vision").settings)
$ uv run python labs/system_config.py
stt    {'device': 'cuda', 'compute_type': 'int8'}
vision {'device': 'cuda', 'compute_type': 'int8'}

No error, no warning, and a config file that now claims a disabled camera wants the GPU. One assignment moved two components. The cause is that ComponentConfig("stt", True, ACCEL) stores a reference to ACCEL, and so does the next line, so both components point at one dict. print(a.settings is b.settings) answers True and ends the investigation in ten seconds. default_factory protects you from the version of this bug the decorator can see, where the shared dict is the class default, and protects you from nothing at the call site. The fix is a fresh dict per component: ComponentConfig("stt", True, {**ACCEL}), or better, spell both out and accept two duplicated words in exchange for two independent components.

Checkpoint, and a system with a table of contents

✓ Checkpoint — what you can now do
  • I can name the three fields of ComponentConfig and the question each one answers about a subsystem.
  • I can say what next() does with its second argument, and what breaks in the caller when it is left off.
  • I can add a GPU or Raspberry Pi profile by writing one more factory, without touching either dataclass.
  • I know why save_config builds its dicts by hand when asdict() exists, and what json.dumps does when handed a dataclass.
  • I can say what a JSON round-trip loses without load_config putting the types back, and why its two .get calls let a config file from an older revision still open.
  • Shown two components whose settings move together, I can name the cause, confirm it with one is comparison, and fix it in the constructor call.
⚡ Exercises — try first, then reveal
Exercise 1 — prove the round trip. Print the result of load_config(CONFIG_PATH) == build_default_config(). Then change one setting in the JSON by hand, reload, and print which component stopped matching.

The dataclass decorator generates __eq__, so that comparison walks the version, then the component list, then every field of every component, and the first run answers True. One word, and it is the strongest claim you can make about a file format: what came back is what went out. Now edit "device" to "cuda" in the file and reload. The answer flips to False and tells you nothing about where. So walk zip(loaded.components, build_default_config().components) and print the pairs where a != b, and you have a config diff in four lines, usable against any file somebody hands you.

Exercise 2 — make the flag mean something. Add disable_component(name) returning True on success and False for an unknown name, then write a speak() that consults the registry and prints the line instead of synthesizing it when TTS is disabled.

disable_component is three lines on top of get_component: fetch, return False if the result is None, otherwise set enabled = False and return True. The interesting half is speak(), where a component the registry marks off has to actually change behaviour, so the function reads comp is not None and comp.enabled before touching the voice model. Run it with the flag flipped both ways and you have a silent mode you can toggle from a config file, which is the first time the registry does something instead of merely describing something.

Exercise 3 — a second profile, and a diff. Write build_pi_config() for a Raspberry Pi: the smaller llama3.2:1b model, memory kept, and vision registered but disabled. Save it to configs/system_config.pi.json and diff the two files.

diff -u configs/system_config.json configs/system_config.pi.json prints a handful of changed lines, and those lines are exactly the decisions that separate the two machines. That is the payoff of keeping configuration as data: a hardware profile becomes something you can read in one screen and review like code. The disabled vision entry is doing real work as documentation, since it records a subsystem that exists in the plan and not yet on the board.

She now has a table of contents. What she does not have is anything that reads it: the listener, the brain and the voice still construct themselves, each one loading a model the moment its module is imported, which makes testing the loop impossible without waiting for every engine to warm up. Next chapter turns that around. The core takes its collaborators as arguments, and a full conversational turn runs end to end on three fake functions before a single real model loads.