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
# 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.
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.
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.
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.
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
- 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.
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.