Proving the Config Before the Loop Starts
A config is a pile of claims about your machine
The core assembles, the health check passes, and the loop is one command away from running. Before it does, it has to read about a dozen numbers and strings: what rate to record at, how many seconds to listen per turn, what signal level counts as silence, which Whisper variant to load, how many turns of conversation to keep, where the voice model lives on disk. Each of those is a claim about the machine you are on. The sample rate claims your capture path and your transcriber agree. The model path claims a file is sitting at that exact location right now.
Scatter those values as literals and every change becomes a hunt: 16000 in
the recorder, "base" in the transcriber, a model path buried in the speech
module. The timing is worse than the hunt. A wrong path does not fail when you set it;
it fails when something opens it, and in a voice loop that is after the microphone has
recorded five seconds, after Whisper has transcribed them, after the language model has
composed a reply. Half a minute of work thrown away, and the message is a file-not-found
error from inside a library you did not write.
The design is two small functions and a rule. One factory builds the whole config in one place; one validator turns it into a list of plain error strings before anything expensive happens. The rule: build the config in one place, prove it in one place, and fail at second zero instead of second thirty.
The component registry answers "what parts exist and what is each one called." This
config answers "what settings does one run use," and it differs in two ways. It is a
plain nested dict, so json.dumps can write it and json.loads
can read it back, which lets you diff the settings a good session ran with against a
bad one's. And it is the thing an operator edits: someone who wants an eight-second
listen window should not have to open a Python file to get one.
Build it, then refuse to trust it
# labs/voice_loop_config.py
import json
from pathlib import Path
SAMPLE_RATE = 16000
TTS_MODEL = Path("configs/en_US-lessac-medium.onnx")
RUN_CONFIG = Path("glados/data/last_run_config.json")
def build_voice_loop_config() -> dict:
return {
"audio": {
"sample_rate": SAMPLE_RATE,
"channels": 1,
"listen_duration": 5,
"silence_threshold": 0.01,
},
}
if __name__ == "__main__":
print(json.dumps(build_voice_loop_config(), indent=2))
$ uv run python labs/voice_loop_config.py
{
"audio": {
"sample_rate": 16000,
"channels": 1,
"listen_duration": 5,
"silence_threshold": 0.01
}
}
A function, not a module-level dict, and the reason is mutation. A module-level dict
is one object shared by every importer, so the first caller that tweaks its copy has
tweaked everyone's. A factory hands each caller a fresh one. SAMPLE_RATE
stays a named constant on top of that because two separate subsystems have to agree
on it, and a number that must match in two places should exist in one.
def build_voice_loop_config() -> dict:
return {
"stt": {
"model_size": "base",
"device": "cpu",
"compute_type": "int8",
"language": "en",
},
"llm": {
"model": "llama3.2:3b",
"max_history": 20,
},
"tts": {
"model": str(TTS_MODEL),
"sample_rate": 22050,
},
"audio": {
"sample_rate": SAMPLE_RATE,
"channels": 1,
"listen_duration": 5,
"silence_threshold": 0.01,
},
}
$ uv run python labs/voice_loop_config.py
{
"stt": {
"model_size": "base",
"device": "cpu",
"compute_type": "int8",
"language": "en"
},
"llm": {
"model": "llama3.2:3b",
"max_history": 20
},
"tts": {
"model": "configs/en_US-lessac-medium.onnx",
"sample_rate": 22050
},
"audio": {
"sample_rate": 16000,
"channels": 1,
"listen_duration": 5,
"silence_threshold": 0.01
}
}
The four sections are the four stages of a turn, in the order a turn runs them, so
every knob a stage owns sits in one block. Two rates now live in the same document
and neither is a typo: 16,000 is what the microphone captures and what Whisper was
trained on, 22,050 is what the voice model produces. Storing the path as
str(TTS_MODEL) keeps the dict JSON-serializable while letting
Path build it with the right separators for your operating system.
REQUIRED_SECTIONS = ("stt", "llm", "tts", "audio")
def validate_config(cfg: dict) -> list[str]:
errors = [f"missing config section: {name}"
for name in REQUIRED_SECTIONS if name not in cfg]
if errors:
return errors
model = Path(cfg["tts"]["model"])
if not model.exists():
errors.append(f"TTS model not found: {model}")
elif not Path(f"{model}.json").exists():
errors.append(f"TTS model card not found: {model}.json")
audio = cfg["audio"]
if audio["sample_rate"] != SAMPLE_RATE:
errors.append(f"audio.sample_rate must be {SAMPLE_RATE} to match the STT model")
if audio["listen_duration"] < 1:
errors.append("audio.listen_duration must be at least 1 second")
if not 0.001 <= audio["silence_threshold"] <= 0.5:
errors.append("audio.silence_threshold must be between 0.001 and 0.5")
if cfg["llm"]["max_history"] < 2:
errors.append("llm.max_history must be at least 2 (one exchange)")
return errors
$ uv run python labs/voice_loop_config.py # before the voice model is in place
Config rejected:
- TTS model not found: configs/en_US-lessac-medium.onnx
Two decisions are doing the work here. The section sweep runs first and returns early, so every lookup below it is safe by the time it executes; skip that and the validator crashes on exactly the malformed input it was hired to describe, which the failure box demonstrates in full. And the checks accumulate into a list instead of raising on the first one, so a run reports a missing model and a nonsense threshold together. An empty list is the unambiguous all-clear.
The bounds themselves are arguments, not decoration. The floor of 0.001 on the silence
threshold rejects a value so low that room hiss reads as speech; the ceiling of 0.5
rejects one so high she ignores you unless you shout. Chapter 4's
SILENCE_THRESHOLD of 0.01 sits comfortably between them. Two is the
smallest history that holds a question and its answer. And the sample rate check
catches the class of bug that produces no error at all: a mismatch that leaves
Whisper transcribing correct audio into confident nonsense.
def save_run_config(cfg: dict, path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(cfg, indent=2))
return path
def preflight(cfg: dict) -> None:
errors = validate_config(cfg)
if errors:
print("Config rejected:")
for error in errors:
print(f" - {error}")
raise SystemExit(1)
saved = save_run_config(cfg, RUN_CONFIG)
print(f"Config is valid. Recorded at {saved}")
def main() -> None:
cfg = build_voice_loop_config()
preflight(cfg)
if __name__ == "__main__":
main()
$ uv run python labs/voice_loop_config.py ; echo "exit=$?"
Config is valid. Recorded at glados/data/last_run_config.json
exit=0
validate_config reports and preflight decides, keeping the
validator a pure function you can test with a hand-built dict. The exit code pays off
later: a supervisor that restarts her on boot can tell "she refused to start" from
"she started" without parsing a word of English. The saved copy answers the question
that arrives three days after a strange session, when you want to know which settings
were actually in effect and memory offers three plausible answers.
Why this works: closing the gap between set and used
Every config value has three moments in its life. It gets written, at edit time. It gets used, somewhere deep in a turn. Between those two, if you are lucky, it gets checked. All the pain lives in the distance between the last two: the further a value travels between validation and use, the less the eventual error resembles the mistake. Validation earns its place by dragging that check as close to edit time as it will go, and startup is the closest point available, the last moment before the program begins work it might have to undo.
A second choice hides in the same code, and chapter 27 made the opposite one on purpose. There, an out-of-range servo angle got clamped: 200 degrees quietly became 180 and the arm moved anyway. Here, an out-of-range threshold gets rejected and the program exits. The dividing line is whether a bad value has an obviously correct nearest legal neighbour. An angle does, and clamping beats crashing when a motor is mid-travel. A missing model path does not, and guessing would mean picking a voice you never chose. Clamp when you can be sure what they meant. Refuse when you would be inventing it.
Everyone writes the validator without the section sweep the first time, because the factory always produces every section. Then someone builds an override layer that passes a partial dict, expecting to be told what is missing:
def validate_config(cfg: dict) -> list[str]:
errors = []
model = Path(cfg["tts"]["model"]) # assumes "tts" is present
if not model.exists():
errors.append(f"TTS model not found: {model}")
return errors
partial = {"audio": {"listen_duration": 5, "silence_threshold": 0.01}}
print(validate_config(partial))
$ uv run python labs/voice_loop_config.py
Traceback (most recent call last):
File "/home/you/GladOS/labs/voice_loop_config.py", line 42, in <module>
print(validate_config(partial))
File "/home/you/GladOS/labs/voice_loop_config.py", line 35, in validate_config
model = Path(cfg["tts"]["model"])
~~~^^^^^^^
KeyError: 'tts'
The diagnostics tool produced an undiagnosed crash. KeyError: 'tts' is
accurate and useless: it names a dict key, not a mistake, and it says nothing about
the two other sections that are also missing. The caret markers Python 3.11 prints
under the failing line point at the subscript itself, telling you the lookup broke and
not the Path call wrapped around it. The fix is stage 3's ordering: check
that the structure exists, return those errors if it does not, and only then reach
inside it. A validator that bad input can crash is one more place bad input crashes
you.
Checkpoint, and a loop cleared for takeoff
- I can explain why the config comes from a factory function instead of a module-level dict, in terms of what one caller's mutation does to another's.
- I can say why the validator sweeps for missing sections and returns before it indexes into any of them.
- I can defend collecting errors in a list against raising on the first one, from the operator's point of view.
- I can argue for each numeric bound in the validator: what a value below the floor or above the ceiling would do to a live turn.
- I know when to clamp an out-of-range value and when to refuse it, and can name a case of each from this book.
- I can read the caret markers in a Python 3.11 traceback and tell which part of an expression raised.
Exercise 1 — overrides without repetition. Write
load_voice_loop_config(overrides) so a caller can change one setting
without restating the other fifteen. Prove the untouched values survive.
A shallow {**defaults, **overrides} would replace the entire
audio section with the one key you passed, so the merge has to
recurse into nested dicts and replace everything else:
def deep_merge(base: dict, override: dict) -> dict:
result = dict(base)
for key, value in override.items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = deep_merge(result[key], value)
else:
result[key] = value
return result
def load_voice_loop_config(overrides: dict) -> dict:
return deep_merge(build_voice_loop_config(), overrides)
cfg = load_voice_loop_config({"audio": {"listen_duration": 8}})
print(cfg["audio"]["listen_duration"], cfg["audio"]["sample_rate"])
That prints 8 16000: the override landed and the sample rate beside it
lived. This is the same layered-defaults idea as chapter 22's preferences loader,
one level deeper.
Exercise 2 — the hole the section sweep leaves. Feed the
stage 3 validator {"stt": {}, "llm": {}, "tts": {}, "audio": {}}.
Predict the result before you run it, then close the gap.
It crashes with KeyError: 'model'. Every section is present, so the
sweep passes, and the very next line reaches for a key inside an empty one. The
sweep proved the outer structure and nothing else. Extend it by describing the
required keys as data and checking them the same way:
REQUIRED_KEYS = {
"stt": ("model_size", "device", "compute_type"),
"llm": ("model", "max_history"),
"tts": ("model",),
"audio": ("sample_rate", "listen_duration", "silence_threshold"),
}
for section, keys in REQUIRED_KEYS.items():
if section not in cfg:
errors.append(f"missing config section: {section}")
continue
for key in keys:
if key not in cfg[section]:
errors.append(f"missing setting: {section}.{key}")
Now the empty-sections dict reports all nine missing settings by name in one run instead of one traceback, and adding a setting later means adding a string to a tuple.
Exercise 3 — check the model that is not a file. Three of
the four sections point at something on disk. The language model does not. Write
a check that the Ollama daemon actually has llama3.2:3b pulled, then
decide whether it belongs in validate_config.
urllib.request.urlopen("http://localhost:11434/api/tags") returns
JSON listing the pulled models, and comparing cfg["llm"]["model"]
against those names catches the common "I never pulled it on this machine"
failure; your listing will differ from anyone else's. The design question is the
interesting half. This check touches the network, so it is slow, and it fails when
Ollama is merely not started yet, a different problem from a wrong setting. Keep
validate_config pure and put this one in the health report from
chapter 33, where checks may talk to running services and a failure already reads
as "a component is down."
She can now refuse to start for a reason she can state in one line. What she still cannot do is act: a reply that says she will turn off the lights is a sentence, not a switch. Next chapter drags a clean command key out of a paragraph of model prose and hands it to the action registry, with a guard so that one badly written handler cannot take the whole loop down with it.