A Reference That Prints Itself
Nine volumes of settings, and no page that holds them
Ask a plain question about her. What rate does she record at? Which header pin drives
the eye tilt servo? What does the API answer on /status, and which topic
does the doorbell publish to? Every one of those has an exact answer, and every answer
lives in a different place: a JSON file of components, a second JSON file of pin claims,
a routing table Flask builds at import, a list of trigger rows in the bridge, a
dictionary of specialists, a column in the memory database. Two of those files only
exist on the bench box. You knew all of it a fortnight ago.
The usual answer is a page in a notes app with the settings copied into it. It is accurate the day you write it and wrong the first time you change a default and forget the copy, and a wrong reference costs more than none at all, because you act on it. In chapter 56 the fix was to stop copying: the voice manual is rendered from the rule rows and the behavior table, so it cannot describe a command that was renamed out from under it. This chapter points the same move at everything else she is made of.
Scope is what makes it a different problem. One generator now reaches into seven subsystems, which means it can fail in seven ways, and some of them are not bugs at all: the laptop has no wiring file because the body is bolted to a shelf in another room. A generator that imports everything and renders in one pass is a generator that produces nothing when any one of those seven is missing. So the design carries two rules. Each section is rendered from the module that owns its facts, and a section that cannot reach its source prints one line saying so instead of taking the document down with it.
The rows you type, and the renderer that measures them
# labs/reference.py
Row = tuple[object, ...] # one record: as many cells as its section has headers
COMMANDS: list[Row] = [
("uv run python labs/wire_core.py", "her main loop: listen, transcribe, answer, speak"),
("uv run python -m labs.glados_api", "serve the HTTP API on port 5000"),
("uv run python -m labs.mqtt_bridge", "join the broker and dispatch house events"),
("uv run python -m labs.hardware_map", "re-check the pin claims, rewrite the pin card"),
("sudo systemctl status glados", "is she running, and did she restart on her own"),
("journalctl -u glados -f", "follow her log while she answers"),
("ollama ps", "which model is resident, and for how long"),
]
if __name__ == "__main__":
print("COMMANDS")
for command, note in COMMANDS:
print(f" {command} = {note}")
$ uv run python -m labs.reference
COMMANDS
uv run python labs/wire_core.py = her main loop: listen, transcribe, answer, speak
uv run python -m labs.glados_api = serve the HTTP API on port 5000
uv run python -m labs.mqtt_bridge = join the broker and dispatch house events
uv run python -m labs.hardware_map = re-check the pin claims, rewrite the pin card
sudo systemctl status glados = is she running, and did she restart on her own
journalctl -u glados -f = follow her log while she answers
ollama ps = which model is resident, and for how long
Start with the rows that have to be typed, because they are the exception the whole
file is organised around. No module in this project knows which shell command you run
when she goes quiet; that fact lives in your hands and nowhere else, so it gets typed
once, here. Each row is a tuple, and the tuple is the record: the fields travel
together, the list of them is iterable, and one loop reaches every field of every row.
Seven loose variables could hold the same text and no loop could reach them. The
Row alias names that record once for the whole file, and it is
deliberately loose about what a cell is, because two sections from now the cells are
integers.
The printing is the part that does not survive. Read the second column and it wanders across the screen, because the commands run from 9 characters to 34 and the equals sign lands wherever the first field happens to end. That ragged right edge is not cosmetic. A column you can scan is a column your eye can drop down without reading, and this document exists to be scanned by someone who has forgotten everything.
from typing import Sequence
def _row(cells: Sequence[object], widths: list[int]) -> str:
return (" " + " ".join(str(c).ljust(widths[i]) for i, c in enumerate(cells))).rstrip()
def _table(title: str, headers: list[str], rows: list[Row]) -> list[str]:
"""One aligned table as a list of lines. Every column as wide as its widest cell."""
widths = [max(len(h), max(len(str(r[i])) for r in rows)) for i, h in enumerate(headers)]
rule = ["-" * w for w in widths]
return [f" {title}", _row(headers, widths), _row(rule, widths),
*(_row(r, widths) for r in rows), ""]
if __name__ == "__main__":
print("\n".join(_table("COMMANDS", ["Command", "What it does"], COMMANDS)))
$ uv run python -m labs.reference
COMMANDS
Command What it does
---------------------------------- ------------------------------------------------
uv run python labs/wire_core.py her main loop: listen, transcribe, answer, speak
uv run python -m labs.glados_api serve the HTTP API on port 5000
uv run python -m labs.mqtt_bridge join the broker and dispatch house events
uv run python -m labs.hardware_map re-check the pin claims, rewrite the pin card
sudo systemctl status glados is she running, and did she restart on her own
journalctl -u glados -f follow her log while she answers
ollama ps which model is resident, and for how long
The width line is the whole trick and it reads inside out. For column
i, the generator len(str(r[i])) for r in rows walks every
row and produces that column's cell lengths; the inner max takes the
longest; the outer max compares it with the header, so a column is never
narrower than its own title. ljust then pads each cell to that number,
which makes column two start at the same offset on every line. The
str() is not decoration: len and ljust work on
strings, and the pin table three stages from here hands this function integers.
_table returns lines instead of printing them, and that single choice is
what lets the same rows go to a terminal, to a file, and later to JSON. It names no
subsystem. It has never heard of GLaDOS. Give it a title, a header list and rows whose
length matches, and it renders. The two-column command table and the four-column
component table coming next need one implementation between them.
Sections that read the system
def component_rows() -> list[Row]:
"""One row per setting of every component the registry declares."""
from labs.system_config import CONFIG_PATH, load_config
rows: list[Row] = []
for comp in load_config(CONFIG_PATH).components:
state = "on" if comp.enabled else "OFF"
for key, value in comp.settings.items():
rows.append((comp.name, state, key, str(value)))
return rows
if __name__ == "__main__":
print("\n".join(_table("COMPONENTS",
["Component", "State", "Setting", "Value"],
component_rows())))
$ uv run python -m labs.reference
COMPONENTS
Component State Setting Value
------------- ----- ------------ -----------------------
audio_capture on sample_rate 16000
audio_capture on channels 1
audio_capture on chunk 1024
stt on model base
stt on device cpu
stt on compute_type int8
stt on sample_rate 16000
wake_word on phrase glados
wake_word on threshold 0.6
tts on voice en_US-lessac-medium
tts on sample_rate 22050
memory on db_path glados/data/memory.db
context on turns 6
context on facts 4
llm on model llama3.2:3b
llm on host http://localhost:11434
safety on policy configs/safety.json
drivers on port /dev/ttyUSB0
drivers on baud 115200
automation on rules configs/automation.json
scheduler on tick_seconds 30
voice_loop on max_turns 0
Twenty-two rows, none of them typed. The nested loop flattens a structure the
reference cannot show directly: the registry holds twelve components, each with a
settings dictionary of its own, and a table wants one fact per line. So the component name and
its on/off state repeat down the left, and every knob in the system gets a row with
its current value beside it. Switch transcription off in the config and this table
says OFF the next time it runs. Add a setting and a row appears. Nobody
edits the reference.
The import sits inside the function, which will look wrong to you and to your linter. Stage 5 is where that pays for itself; for now, notice that it also states a dependency exactly where the dependency is used, so the section and the module it reads are one screen apart.
def pin_rows() -> list[Row]:
"""Every header pin the body claims, keyed both ways the pin is named."""
from labs.hardware_map import (BCM_TO_PHYSICAL, CONFIG_PATH, extract_claims,
load_config)
if not CONFIG_PATH.exists():
raise FileNotFoundError(CONFIG_PATH) # no body attached to this machine
claims = extract_claims(load_config())
return [(pin, BCM_TO_PHYSICAL.get(pin, "?"), owner)
for pin in sorted(claims) for owner in claims[pin]]
def route_rows() -> list[Row]:
"""The API as Werkzeug holds it, not as anyone remembers writing it."""
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()): # importing it builds her core, out loud
from labs.glados_api import app
return [(",".join(sorted(rule.methods - {"HEAD", "OPTIONS"})), str(rule))
for rule in sorted(app.url_map.iter_rules(), key=str)]
def topic_rows() -> list[Row]:
"""What she subscribes to, what she publishes, and what fires an action."""
from labs.mqtt_bridge import STATUS_TOPIC, SUBSCRIPTIONS, TRIGGERS
rows = [(topic, f"qos {qos}", "subscribed") for topic, qos in SUBSCRIPTIONS]
rows.append((STATUS_TOPIC, "retained", "published: online, offline on the will"))
return rows + [(t["filter"], t["payload"], f"dispatches {t['action']}") for t in TRIGGERS]
def specialist_rows() -> list[Row]:
"""The team, and the first few words that route a request to each member."""
from labs.agent_team import SPECIALISTS
return [(name, "yes" if spec["executes"] else "no",
" ".join(sorted(spec["keywords"])[:5]) + " ...")
for name, spec in SPECIALISTS.items()]
$ uv run python -m labs.reference # on the bench box, where the body is
PINS
BCM Phys Owner
--- ---- -----------------------
2 3 bus:i2c1
3 5 bus:i2c1
4 7 bus:spi4
5 29 bus:spi4
6 31 bus:spi4
7 26 bus:spi4
9 21 bus:spi0
10 19 bus:spi0
11 23 bus:spi0
12 32 servo:eye_pan
13 33 servo:eye_tilt
17 11 servo:bench_test
18 12 i2s:amp_max98357a:bclk
19 35 i2s:amp_max98357a:lrclk
21 40 i2s:amp_max98357a:din
ROUTES
Methods Path
------- -----------------------
POST /ask
GET /jobs/<job_id>
POST /speak
GET /static/<path:filename>
GET /status
TOPICS
Topic Payload Effect
------------------------- -------- --------------------------------------
home/+/+/event qos 1 subscribed
home/+/+/state qos 1 subscribed
glados/status retained published: online, offline on the will
home/+/motion/event detected dispatches greet_arrival
home/front/doorbell/event * dispatches announce_visitor
home/+/window/state open dispatches note_open_window
SPECIALISTS
Agent Executes Keywords
------ -------- ----------------------------------------
facts no define explain how what when ...
house yes camera door fan heater lamp ...
recall no earlier history recall remember said ...
Four readers, four kinds of source, one row format between them. The pin
rows come out of a file you edited by hand, so the reference is repeating your own
declaration back to you with the physical positions filled in. The routes come from
something nobody wrote down at all: app.url_map is assembled by the
decorators as Flask imports the module, so /static appears in the table
even though no chapter ever asked for it, and a route you add next spring appears
without an edit here. The topics come from three separate constants in the bridge, and
printing them together is the first time the subscriptions and the trigger filters
have been visible side by side. The specialists come from a dictionary that is code
and data at once, and the keyword column is cut to five words on purpose, because a
reference names the words that route a request without reprinting the router.
One of those four imports is not free. Importing labs.glados_api runs
that module from top to bottom, and it builds her core while it loads, announcing the
speech model and the synthesizer on standard output: sensible when you start the API
yourself, and destructive here, because those two lines would land in
docs/reference.txt above the banner with nothing to mark them as not
being part of the document. So route_rows borrows standard output for the
length of the import. contextlib.redirect_stdout points it at a throwaway
buffer and puts the real stream back when the with block ends, whether
the import returned or raised. A generator that renders from live modules has to hold
on to its own output stream, because every import it performs is somebody else's
startup code, free to print.
pin_rows passes integers into a renderer that pads strings, and the
str() in _row absorbs that. It also raises
FileNotFoundError when the wiring file is absent instead of calling into
the loader, and the worked failure at the end of this chapter is the reason that line
is there.
One document, seven ways for it to fail
import sys
from datetime import datetime
from typing import Callable
Reader = Callable[[], list[Row]]
BANNER = "=" * 62
def retrieval_rows() -> list[Row]:
"""What she searches by meaning, and how much of it carries a vector."""
import sqlite3
from glados.embeddings import DB_PATH, EMBED_MODEL
with sqlite3.connect(DB_PATH) as conn:
total = conn.execute("SELECT count(*) FROM knowledge").fetchone()[0]
embedded = conn.execute("SELECT count(*) FROM knowledge WHERE embed_model = ?",
(EMBED_MODEL,)).fetchone()[0]
return [("database", str(DB_PATH)),
("embedding model", EMBED_MODEL),
("knowledge rows", f"{embedded} of {total} embedded")]
# (title, where the facts come from, headers, the function that reads them)
SECTIONS: list[tuple[str, str, list[str], Reader]] = [
("COMMANDS", "typed here", ["Command", "What it does"], lambda: COMMANDS),
("COMPONENTS", "configs/system_config.json", ["Component", "State", "Setting", "Value"], component_rows),
("PINS", "configs/hardware.json", ["BCM", "Phys", "Owner"], pin_rows),
("ROUTES", "labs.glados_api", ["Methods", "Path"], route_rows),
("TOPICS", "labs.mqtt_bridge", ["Topic", "Payload", "Effect"], topic_rows),
("SPECIALISTS", "labs.agent_team", ["Agent", "Executes", "Keywords"], specialist_rows),
("RETRIEVAL", "glados/data/memory.db", ["Key", "Value"], retrieval_rows),
]
def render_section(title: str, source: str, headers: list[str],
reader: Reader) -> tuple[list[str], str]:
"""The section's lines, and one audit line saying how it went."""
try:
rows = reader()
except Exception as exc:
note = f"unavailable: {type(exc).__name__}: {exc}"
return [f" {title}", f" {note}", ""], note
if not rows:
return [f" {title}", " nothing declared", ""], f" 0 rows {source}"
return _table(title, headers, rows), f"{len(rows):>2} rows {source}"
def build_reference(stamp: str) -> tuple[list[str], list[str]]:
doc = [BANNER, " GLaDOS system reference", f" generated {stamp}", BANNER]
audit: list[str] = []
for title, source, headers, reader in SECTIONS:
lines, note = render_section(title, source, headers, reader)
doc += lines
audit.append(f" {title:<12} {note}")
return doc, audit
def main() -> None:
doc, audit = build_reference(datetime.now().strftime("%Y-%m-%d %H:%M"))
print("\n".join(doc)) # the document
available = sum(1 for line in audit if "unavailable" not in line)
print("\n".join(audit), file=sys.stderr) # the commentary
print(f"{available} of {len(SECTIONS)} sections available, {len(doc)} lines",
file=sys.stderr)
if __name__ == "__main__":
main()
$ uv run python -m labs.reference > docs/reference.txt # bench box; your timestamp and counts will differ
COMMANDS 7 rows typed here
COMPONENTS 22 rows configs/system_config.json
PINS 15 rows configs/hardware.json
ROUTES 5 rows labs.glados_api
TOPICS 6 rows labs.mqtt_bridge
SPECIALISTS 3 rows labs.agent_team
RETRIEVAL unavailable: OperationalError: no such column: embed_model
6 of 7 sections available, 89 lines
$ sed -n '80,89p' docs/reference.txt
SPECIALISTS
Agent Executes Keywords
------ -------- ----------------------------------------
facts no define explain how what when ...
house yes camera door fan heater lamp ...
recall no earlier history recall remember said ...
RETRIEVAL
unavailable: OperationalError: no such column: embed_model
The redirection is the design, not a trick of the demonstration. The document is this
program's output and belongs in a file; the audit is commentary about the run, so it
goes to standard error and stays on your terminal while the document flows past it
into docs/reference.txt. Every command you have used all book long makes
the same split, which is why curl -s can be piped into a parser while its
progress meter still reaches your eyes.
Now read the seventh line. The knowledge table on this machine has never been through
the embedding backfill, so asking for its embed_model column raises
sqlite3.OperationalError inside retrieval_rows, the guard
catches it, and the reference reports the failure as a fact about this machine. Six
tables still printed. That is the difference between a document that degrades and a
document that dies: the same broken column, in the version without
render_section, produces a traceback and an empty file, and the six
subsystems that were working become invisible along with the one that was not.
Three details in the guard are deliberate. It catches Exception and not
BaseException, so Ctrl-C still stops the program instead of
being filed as a missing section. It records the exception's class name next to its
message, because OperationalError: no such column tells you what to fix
and no such column alone does not. And the empty-rows branch comes before
_table ever sees the data, since the width computation calls
max() over the rows and an empty sequence raises
ValueError: max() arg is an empty sequence. A section with nothing in it
is a legitimate state, not an error.
Convention says imports go at the top of the module, and a linter will offer to move
them there for you. This file is the exception that proves what the convention is for.
An import at the top runs when the module loads, which is before
build_reference exists, let alone the try block inside it. Install the
project on a Jetson without the Flask extra and a top-level
from labs.glados_api import app ends the program with
ModuleNotFoundError before one row of the pin table has been rendered.
Moved inside the reader, the same failure lands in the guard and costs you one section
out of seven. The general form: an import is executable code, and where you run it
decides who can catch it.
Why this works: the document is a query, not a copy
Three kinds of thing live in this file and they never touch. There is data, which is
rows of tuples. There is a renderer, which turns any headers and rows into aligned text
and knows nothing about this project. And there are readers, one per subsystem, whose
only job is to reach a source and return rows. A new subsystem in volume 10 costs one
reader and one line in SECTIONS; the renderer does not change, and neither
does anything already in the table.
The freshness comes from the direction the facts flow. A typed reference is a copy of the truth, and copies drift silently because nothing connects them to the original. A rendered reference is a query against the truth, run at the moment you ask, so the only way it can go stale is for its source to be gone, and that case is exactly what the guard reports out loud. Chapter 56 bought that property for the voice manual by joining two dispatchers on the action name. This file buys it seven times over, by never storing a fact that a module already stores.
The renderer has one assumption in it: every row carries as many cells as there are
headers. Hand it a three-field tuple where four were promised and r[i]
raises IndexError in the width computation, before a single line prints.
That assumption is worth stating because of what stage 5 does to it. Since the reader
runs inside the guard, even a bug of your own in a section reader now comes back as one
unavailable row in the audit instead of a dead document. That is the move chapter 92
made one layer down, where a subsystem check reports its failure as a returned value.
A monitor that survives a dead subsystem and a document that survives a missing source
are the same idea at two sizes, and the standing monitor is one reader away from being
an eighth section here.
Before pin_rows tested for the file itself, it did the obvious thing and
called the loader. Run the reference on the laptop, where the body config has never
existed, and this happens:
def pin_rows() -> list[Row]:
from labs.hardware_map import BCM_TO_PHYSICAL, extract_claims, load_config
claims = extract_claims(load_config()) # BUG: this call can end the process
return [(pin, BCM_TO_PHYSICAL.get(pin, "?"), owner)
for pin in sorted(claims) for owner in claims[pin]]
$ uv run python -m labs.reference > docs/reference.txt $ echo $? 0 $ cat docs/reference.txt no configs/hardware.json; wrote the draft. Edit it to match your wiring, then re-run.
No traceback. No audit lines. A status of 0, which every script that ever checks this will read as success, and a document holding one sentence about a file you did not ask about. The guard was right there and it did not fire.
Work backwards from the one line that did appear. Nothing in the reference prints that
sentence, so grep the sources for it: the loader in the hardware map writes a draft
config on a machine that has never had one, tells you so, and calls
sys.exit(0). That is friendly behaviour in a script you run directly and
hostile behaviour in a function something else imports, because
sys.exit raises SystemExit, and SystemExit
inherits from BaseException rather than Exception. The guard
declined to catch it on purpose, the exception travelled up through
build_reference and main, and the interpreter did what the
exception asked: exit, with code 0, before the accumulated document was ever printed.
Widening the guard to except BaseException makes the symptom go away and
takes Ctrl-C with it. The fix is the precondition in stage 4: ask whether
the file exists, raise a normal exception when it does not, and never let a function
you imported decide that your program is finished.
$ uv run python -m labs.reference 2>&1 >/dev/null | grep PINS # the guarded version, same laptop
PINS unavailable: FileNotFoundError: configs/hardware.json
That redirection reads backwards and is worth reading twice: 2>&1
points standard error at wherever standard output currently goes, the terminal, and
>/dev/null then moves standard output alone into the bin. What is left
on the pipe is the commentary with the document discarded, which is how you grep an
audit that shares a terminal with 89 lines of tables.
Checkpoint, and a volume that ends by writing itself down
- I can read the width expression in
_tablefrom the inside out and say what each of the twomaxcalls compares. - I can name the four fields of a row in
SECTIONS, and say what adding a subsystem costs in this file. - I can explain why the imports sit inside the readers, and predict what a missing package does to the document in each of the two placements.
- I know which exception class the guard refuses to catch, why that refusal is
correct, and what
sys.exitin an imported function does to a half-built document. - I can say which stream the tables go to and which stream the audit goes to, and redirect either one without losing the other.
- I can look at seven audit lines and tell which machine produced them.
Exercise 1 — the same rows, as JSON. Add a
--json flag that emits every section as records instead of tables, so
another program can read the reference.
Zip the headers onto each row and dump the result:
[dict(zip(headers, row)) for row in rows], collected into an object
keyed by section title, with the unavailable sections carrying their note instead
of a list. Print it with json.dumps(..., indent=2) and pipe it
through python -m json.tool to prove it parses. Two renderings of one
set of rows is the payoff for having _table return lines rather than
print them, and the JSON is what a config validator or a status page would consume.
Exercise 2 — make the bench box prove itself. Add a fifth field to each section row saying whether it is required on this machine, and exit non-zero when a required section comes back unavailable.
Mark COMPONENTS and PINS required, leave the rest
optional, collect the required titles whose note starts with
unavailable, and finish main with
sys.exit(1 if missing else 0). Run it on the laptop and read
echo $?: a 1, because the wiring file is not there. Run it on the
bench box and get a 0. The reference has stopped being a document you read and
become a command that can fail, which is what lets you put it in the pre-start
check that runs before her service does.
Exercise 3 — numbers on the right. The pin table's BCM and Phys columns are left-aligned, so 2 and 21 do not line up on their last digit. Give each section an alignment string and honour it.
Add a per-section string such as "rrl", one character per column, and
pick rjust or ljust in _row accordingly with
"l" as the default when the string is shorter than the row. Re-render
the pin table and the two number columns snap into place while the owner column
stays where it was. Then look at the sample rates in the component table, where
every value shares one column with model names, and decide whether alignment is
really a property of a column or of a cell.
Look at what volume 9 gave her. She finds a fact by what it means, because every line of her knowledge and every question put to her became a vector and a cosine score decides which lines are close, where a substring search only ever found the words somebody happened to type. She has an eye that reports: a frame is captured, checked for brightness and contrast on your own machine, compressed, turned into text and posted to a model that answers in one sentence. She hears in frames now instead of fixed seconds, quick to start recording and slow to give up on you, so the length of a recording is decided by the person speaking. She begins talking at the first full sentence while the rest of the reply is still being generated. She answers HTTP requests from anything on your network, validated at the boundary and never blocking on the audio. She reaches every device in the house through one broker and a four-level namespace where a retained state and a passing event cannot be confused. She routes a request to the specialist whose keywords score highest and folds the answer back into her own voice. A monitor watches her for days rather than only at startup, because every check returns its failure instead of raising it. And today all of that wrote itself down.
Run docs/reference.txt through git diff after a month of work
and you get something no notes page has ever offered: a record of what actually changed
about her, computed from her own files, with the sections that were missing named as
missing. That is the last piece of software volume 9 owes you. Everything in it answers,
reacts, or reports.
What none of it does is reach. Her arm still moves the only way it has ever moved: you give each joint an angle and she obeys, so the request "put your gripper on that mug" has no answer she can compute. Volume 10 gives her one. It solves backwards from a point in space to the joint angles that arrive there, ranks depth out of a single camera so the mug has a distance and not only a direction, holds one connection open to a browser so a page repaints the moment a subsystem changes, reads loudness and pitch to hear what a voice meant as well as what it said, and keeps what she learns across days without the prompt growing longer every week. Then one full day, start to finish, with her running the house you live in.