GLaDOS Vol 2 · Her Mind
ch 17 / 99
Chapter 17

Rules, Not Branches

The if/elif forest

Home automation reduces to one sentence: when X happens, do Y. Lights off when someone says "lights off," a timer when someone says "set timer." The first version is trivial, and the trap is exactly that it stays trivial-looking while it rots: each rule becomes a branch in a growing if/elif chain, and by the twentieth automation, adding the twenty-first means editing the matching function, re-reading the whole chain to avoid breaking the order, and re-testing logic that already worked. The rules and the machinery are tangled, so touching either risks both. And a non-programmer can never add a rule at all, which matters in a machine meant to run a household.

The untangling is a pattern you now know by heart, at its third and cleanest application: rules are data; handlers are code; a registry bridges them by name. Her personality was data (chapter 9). Her alert routing was data (chapter 16). Now her behavior joins them, and the engine that reads the rules will never need editing again.

Data, registry, dispatch

▣ Build · stage 1 — rules as rows, matched by substring
# labs/automation.py
RULES: list[dict] = [
    {"trigger": "lights off", "action": "turn_off_lights"},
    {"trigger": "set timer", "action": "set_timer"},
    {"trigger": "play music", "action": "play_music"},
]

def match_rules(text: str, rules: list[dict]) -> list[dict]:
    lower = text.lower()
    return [r for r in rules if r["trigger"] in lower]

if __name__ == "__main__":
    for hit in match_rules("Hey GLaDOS, lights off please.", RULES):
        print("matched:", hit["trigger"], "->", hit["action"])
$ uv run python labs/automation.py
matched: lights off -> turn_off_lights

A rule is a dict with two strings, and nothing else lives here: no functions, no branching, no imports. The matcher lowercases once and returns every rule whose trigger appears, because "lights off and play music" legitimately matches two. Notice what the matcher does not know: what any action does. It traffics in names. That ignorance is the design.

▣ Build · stage 2 — handlers register themselves
from typing import Callable

ACTION_REGISTRY: dict[str, Callable] = {}

def register_action(name: str):
    def decorator(fn: Callable):
        ACTION_REGISTRY[name] = fn
        return fn
    return decorator

@register_action("turn_off_lights")
def turn_off_lights(text: str):
    print("[ACTION] Turning off lights.")

@register_action("set_timer")
def set_timer(text: str):
    print("[ACTION] Setting a timer.")

@register_action("play_music")
def play_music(text: str):
    print("[ACTION] Playing music.")

if __name__ == "__main__":
    print("registered:", list(ACTION_REGISTRY))
    ACTION_REGISTRY["set_timer"]("test")
$ uv run python labs/automation.py
registered: ['turn_off_lights', 'set_timer', 'play_music']
[ACTION] Setting a timer.

Your first decorator with a job. @register_action("name") runs at import time, receives the function below it, stores it in the registry under the name, and returns it unchanged, so the function still works normally and is now also findable by string. Dispatch stops being a branch you read top to bottom and becomes a dictionary lookup. Adding behavior means writing one function and decorating it; the engine does not know or care.

▣ Build · stage 3 — the engine, complete
def run_automation(text: str) -> None:
    matched = match_rules(text, RULES)
    if not matched:
        print(f"No automation matched for: '{text}'")
        return
    for rule in matched:
        action_fn = ACTION_REGISTRY.get(rule["action"])
        if action_fn:
            action_fn(text)
        else:
            print(f"[WARN] No handler for action: {rule['action']}")

if __name__ == "__main__":
    run_automation("Hey GLaDOS, lights off please.")
    run_automation("GLaDOS, set timer for 5 minutes.")
    run_automation("Turn on the fan.")
$ uv run python labs/automation.py
[ACTION] Turning off lights.
[ACTION] Setting a timer.
No automation matched for: 'Turn on the fan.'

The whole engine is a dozen lines, and every edge has a decided behavior. No match: say so and return, informative and calm. A rule naming a handler that was never registered: .get() hands back None instead of raising, so one broken row warns and the loop continues to the next rule. An engine that crashes on its worst rule loses all its good ones, and this one refuses to. "Turn on the fan" failing is also the correct result today; it becomes a one-dict fix the moment you own a smart fan, which is the entire pitch.

◆ Note — where the rules will actually live

RULES sits in the Python file for teaching, and nothing about the engine cares: the list could load from configs/automations.json with chapter 9's load-or-default pattern, and in chapter 34's production loop it does exactly that. That is the moment a non-programmer in your house gains the power to add "goodnight" as a trigger without anyone opening an editor. Real smart-home platforms with real device protocols are Automation Zero's territory; what she needs from this chapter is the engine, and the engine is done.

Why this works: the dispatch table

An if/elif chain hard-codes two decisions into one structure: which condition to test, and what to do about it. The engine splits them. RULES answers "which trigger?", ACTION_REGISTRY answers "what to do?", and the action name is the bridge, a string linking a data row to a function with neither side seeing the other's internals. It is chapter 15's lesson in a new room: there, publishers and subscribers shared only an event name; here, rules and handlers share only an action name. Both are dispatch tables. Once you see the pattern, you will find it in web routers, game input maps, and every plugin system you ever open, because "look the behavior up by name" is how code stays still while data moves.

⚠ Worked failure — the greedy trigger

A month in, you add a rule with a generous trigger, and the house gets weird:

RULES.append({"trigger": "light", "action": "turn_on_lights"})
$ uv run python labs/automation.py
You: GLaDOS, lights off please.
[ACTION] Turning off lights.
[ACTION] Turning on lights.

"light" is a substring of "lights off," so both rules match and both handlers fire, in list order: the lights turn off and back on, and she reports success twice. Nothing crashed; the data was wrong in a way the engine faithfully executed. Substring matching is doing exactly what stage 1 said it would, and this is its cost: triggers are not words, they are fragments, and short fragments are greedy. The cheap mitigations are longer triggers and an exclusive flag per rule ("stop after first match"); the real fix, matching intent instead of substrings, is what happens in chapter 35 when the language model starts choosing the action. Until then: name your triggers like you name your variables, specifically.

Checkpoint, automated

✓ Checkpoint — what you can now do
  • I can say what the if/elif forest costs at twenty rules, and who it locks out entirely.
  • I can write the decorator-registry idiom from memory and explain when the registration actually runs.
  • I can trace one utterance through match, lookup, and dispatch, including both failure branches.
  • I can explain why the matcher returning multiple rules is a feature, and when it becomes the greedy-trigger bug.
  • I can name the two other places this book already used behavior-as-data, without looking.
⚡ Exercises — try first, then reveal
Exercise 1 — the twenty-first automation. Add a "goodnight" rule that fires a goodnight_routine handler which prints three actions (lights, thermostat, an insincere "sleep well"). Count the lines you touched outside the new code.

One dict appended to RULES, one decorated function: zero edits to the engine, the matcher, or any existing rule. Compare that against the same feature in an if/elif chain, where it lands inside a function every other rule shares. The diff is the argument.

Exercise 2 — rules from disk. Move RULES to configs/automations.json, loaded with the chapter 9 pattern. What new failure mode did you just accept, and which chapter's failure box already showed it to you?

Hand-edited JSON means hand-made syntax errors: the trailing comma from chapter 9's failure box now breaks automations instead of personality. Same error, same defense (catch JSONDecodeError, report line-and-column, fall back to the last good rules), and the same lesson compounding: every file a human edits needs a loader that expects humanity.

Exercise 3 — wire it into her. Subscribe run_automation to the bus's speech_detected event, after the wake word. Say "lights off." What is the full path, module by module, from your voice to the print?

Mic capture → silence gate → Whisper → wake match → publish speech_detected → bus → run_automation → matcher → registry → handler. Nine hops, and no module in the chain imports more than its neighbors' data types. You built every link, you can name every link, and when volume 5 swaps the print for a real relay, eight of the nine links will not know it happened.

She follows orders written as data. The next step is spookier: noticing that you give the same orders at the same times, and starting to anticipate them. Habits, it turns out, are just counts with a clock attached.