GLaDOS Vol 9 · Sharper Senses
ch 93 / 99
Chapter 93

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

▣ Build · stage 1 — the one table no module can produce
# 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.

▣ Build · stage 2 — measure every column, then print
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

▣ Build · stage 3 — the components, straight out of the registry
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.

▣ Build · stage 4 — four more subsystems, four more readers
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

▣ Build · stage 5 — the last reader, the section table, and a guard around each one
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.

◆ Note — why every import in this file sits inside a function

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.

⚠ Worked failure — the run that succeeded at nothing

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

✓ Checkpoint — what you can now do
  • I can read the width expression in _table from the inside out and say what each of the two max calls 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.exit in 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.
⚡ Exercises — try first, then reveal
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.