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.
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
# 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.
@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.
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.
# 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.
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
- I can name the three fields of
ComponentConfigand 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_configbuilds its dicts by hand whenasdict()exists, and whatjson.dumpsdoes when handed a dataclass. - I can say what a JSON round-trip loses without
load_configputting the types back, and why its two.getcalls 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
iscomparison, and fix it in the constructor call.
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.