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

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.

◆ Note — this is not chapter 31's registry wearing a hat

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

▣ Build · stage 1 — constants, and the smallest config that prints
# 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.

▣ Build · stage 2 — one section per subsystem
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.

▣ Build · stage 3 — the validator, sections first
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.

▣ Build · stage 4 — preflight, and a record of what ran
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.

⚠ Worked failure — the validator crashes on a broken config

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

✓ Checkpoint — what you can now do
  • 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.
⚡ Exercises — try first, then reveal
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.