Habits Are Counts
Anticipation is memory plus arithmetic
The automation engine reacts: trigger arrives, action fires. The thing that makes an assistant feel uncanny is the other direction, anticipation: noticing you kill the lights every weeknight around ten, and offering before you ask. To anticipate, she has to remember what happened and when, then spot the repetition in her own records. Both halves are things you already built. This chapter just introduces them to each other.
The reflex is to reach for machine learning: train a model, build a pipeline, tune it. For a household with a few dozen action types, that is days of infrastructure to predict what a child could guess, and it costs you the one question that matters in a home: why did you suggest that? An opaque model shrugs. A count answers: "because you did it three times at this hour." When she eventually earns real learned models (volume 9 and beyond), it will be for problems counting cannot touch; the discipline of reaching for the simple tool first is half of what this chapter teaches.
The design in one line: log every action with a timestamp, and a prediction is the most frequent past action in the current hour, surfaced as a suggestion, never fired as a command.
Record, bucket, rank, suggest
# labs/predictive.py
import sqlite3
from datetime import datetime
def init_db(conn: sqlite3.Connection) -> None:
conn.execute("""
CREATE TABLE IF NOT EXISTS automation_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
triggered_at TEXT NOT NULL
)
""")
conn.commit()
def log_action(conn: sqlite3.Connection, action: str) -> None:
conn.execute(
"INSERT INTO automation_log (action, triggered_at) VALUES (?, ?)",
(action, datetime.now().isoformat()),
)
conn.commit()
conn = sqlite3.connect(":memory:")
init_db(conn)
log_action(conn, "play_music")
log_action(conn, "play_music")
count = conn.execute("SELECT COUNT(*) FROM automation_log").fetchone()[0]
print(f"Rows logged: {count}")
$ uv run python labs/predictive.py
Rows logged: 2
One table, two columns that matter, and a recorder small enough to bolt onto
chapter 17's dispatcher in a single line (call log_action right
after the handler runs). The timestamp is captured at write time because
prediction needs when, not just what: every row carries the
context we will group by. One deliberate difference from chapter 11: this
timestamp is local time, not UTC, because "ten at night" is a fact about your
evening, and bucketing by UTC hour would split a habit across two buckets the
week the clocks change.
def predict_next_action(conn: sqlite3.Connection, top_n: int = 1) -> list[dict]:
current_hour = datetime.now().hour
rows = conn.execute(
"""
SELECT action, COUNT(*) AS freq
FROM automation_log
WHERE CAST(strftime('%H', triggered_at) AS INTEGER) = ?
GROUP BY action
ORDER BY freq DESC
LIMIT ?
""",
(current_hour, top_n),
).fetchall()
return [{"action": r[0], "frequency": r[1]} for r in rows]
for action in ["play_music", "play_music", "turn_off_lights", "play_music"]:
log_action(conn, action)
print(predict_next_action(conn, top_n=3))
$ uv run python labs/predictive.py
[{'action': 'play_music', 'frequency': 3}, {'action': 'turn_off_lights', 'frequency': 1}]
The whole predictor is one SQL statement. strftime('%H', ...)
extracts the hour from each ISO timestamp, the WHERE keeps only rows
from the current hour-of-day (across every day she has ever logged),
GROUP BY collapses them per action, and the count ordering floats
the habit to the top. The CAST is load-bearing:
strftime returns the zero-padded string "08" while
Python's .hour is the integer 8, and in SQLite
"08" = 8 is false. Compare like with like or match nothing,
silently. The failure box gives that bug its full runtime.
def suggest(conn: sqlite3.Connection) -> None:
predictions = predict_next_action(conn)
if predictions:
top = predictions[0]
print(f"Suggestion: '{top['action']}' (seen {top['frequency']}x this hour)")
else:
print("No prediction - not enough history for this hour.")
suggest(conn)
$ uv run python labs/predictive.py
Suggestion: 'play_music' (seen 3x this hour)
predict_next_action returns pure data; suggest owns the
presentation. The split is chapter 2's returns-rule at system scale, and it is
what lets the same predictor feed a console print today, her voice tomorrow
("You usually want music around now. Shall I?"), and chapter 19's scheduler the
day you decide some suggestions deserve to become actions. That last step is a
policy decision, not a technical one, and the policy this book ships is
conservative on purpose: she suggests; you decide. An assistant
that guesses wrong out loud is charming. One that acts wrong on its own is a
problem, and volume 6's permission system is where that line gets a formal
keeper.
Why this works: throwing away the right information
A full timestamp like 2026-08-21T22:10:05 is too specific to ever
repeat, and prediction needs repetition. strftime('%H', ...)
manufactures a recurring context by discarding everything that does not repeat
(year, date, minute, second) and keeping the one field that does: the hour. Every
ten p.m. she has ever seen collapses into bucket 22, and inside a
bucket, counting is understanding. The generalization is worth pocketing: most
"pattern detection" is choosing what to throw away. Bucket by
%w and you get day-of-week habits; by %H:%M in
ten-minute bins and you get sharper ones; every choice trades sample size against
precision, on data you can inspect with one SELECT.
The predictor without the CAST, which is how everyone writes it
first:
WHERE strftime('%H', triggered_at) = ? -- comparing "08" to 8
$ uv run python labs/predictive.py
[]
No prediction - not enough history for this hour.
Rows exist, the query is syntactically perfect, and the result is empty forever:
the string "08" never equals the integer 8, so the
filter passes nothing, and "no prediction" reads exactly like "not enough
history yet." You could ship this bug for a month. The debugging move that
catches it in one minute is the same one from chapter 14: look at the actual
values. Run SELECT strftime('%H', triggered_at), typeof(strftime('%H',
triggered_at)) FROM automation_log LIMIT 3 and the word
text in the output ends the mystery. Types at boundaries, one more
time, with feeling.
Checkpoint, one step ahead
- I can argue for counts over a trained model at household scale, on explainability and on cost.
- I can write the bucket-and-rank query and say what each clause discards or keeps.
- I know why this table stores local time when chapter 11's stored UTC, and can defend both.
- I can explain the CAST, and I know the SELECT that exposes the bug when the filter silently matches nothing.
- I can state the suggest-don't-act policy and name the volume where acting gets a permission system.
Exercise 1 — hook it to the engine. Add the
log_action call to chapter 17's dispatcher and live with it for a
few days, then run suggest() at different hours. When does it
first say something true about you?
Usually within a week, and usually something mundane: music in the evening, a timer around cooking hours. The interesting part is the miss pattern — suggestions early in an hour lean on last week's data, and one unusual evening pollutes a thin bucket. Small data is honest data; it just needs humility, which is what the frequency number in the output is for.
Exercise 2 — weekday versus weekend. Extend the
bucket to hour plus day-type (weekday/weekend) using
strftime('%w', ...). What happened to your sample sizes, and was
it worth it?
Every bucket got thinner: the same history now splits across twice the contexts, so predictions start later and wobble more, in exchange for not suggesting the work playlist on Saturday. That trade (context precision against sample size) has no free answer, and you just made it consciously with a one-line SQL change, which is more than most recommendation systems can say for themselves.
Exercise 3 — let her say it. Wire the top suggestion into the voice loop's idle moments, at most once per hour, in character. What does the rate limit protect?
Your sanity, and the illusion. A suggestion once an evening feels like attentiveness; the same suggestion every ten minutes feels like a smoke alarm with opinions. The once-per-hour memory is chapter 19's idempotency guard arriving one chapter early, and the phrasing job ("You usually want music now. I suppose I could arrange that.") is chapter 12's mood system earning its keep.
She notices patterns and mentions them. The missing piece is initiative with a clock: briefings at eight, checkpoints every half hour, suggestions that fire when the hour arrives instead of when you happen to speak. Next chapter builds the scheduler, and with it the two classic timing bugs everyone ships once.