GLaDOS Vol 5 · Alive on the Bench
ch 50 / 99
Chapter 50

The Research Archive

The fact you learn twice

This volume produced a pile of facts that live in none of its code. The servo in the right elbow reaches 175 degrees when you command 180, repeatably. Whisper transcribes a near-silent buffer as a polite "Thank you." A restart storm comes from a watchdog that fires more than once for a single stall. None of those is a bug report and none is a comment in a file. Each is a small piece of knowledge you paid an afternoon for, and each matters exactly once: on the day, weeks from now, when you walk into the same wall and do not recognize it.

The usual home for such facts is a notes.txt, or a terminal scrollback, or forty browser tabs you are afraid to close. All three fail the same way, and the failure is not storage. The text is right there on disk. What fails is retrieval. Plain text has no fields, so you cannot ask it for everything you learned about servos, and nothing inside her can ask it anything at all. Over-correcting fails too: a schema, a migration and a query language for eleven notes is enough friction that you quietly stop taking notes. Both roads end in the same place.

So the closing rule of the volume: a note is a typed record that stamps its own time, the archive is a list of those records with load and save wrapped around it, and search is a filter over that list. Sixty lines, no server, no schema, and one JSON file you can open in an editor, diff, and commit beside the code the notes are about.

◆ Note — she already has a memory; this one is yours

Chapter 37 gave her a SQLite knowledge table with a confidence column, and this is not a replacement for it. That store answers a question asked mid-conversation, in milliseconds, and has to choose which single fact is trustworthy enough to enter a prompt. This archive answers a question you ask at 1 a.m. with a soldering iron cooling next to you. Different owner, different reader, different lifetime, so a different store: hers is a database she queries, yours is a file you read. The last build stage links the two directions anyway.

Stamp it, store it, find it

▣ Build · stage 1 — a record that dates itself
# labs/research_archive.py
from dataclasses import dataclass, field
from datetime import datetime


@dataclass
class ResearchNote:
    title: str
    content: str
    tags: list[str] = field(default_factory=list)
    created_at: str = ""
    references: list[str] = field(default_factory=list)

    def __post_init__(self) -> None:
        if not self.created_at:
            self.created_at = datetime.now().isoformat(timespec="seconds")


if __name__ == "__main__":
    note = ResearchNote(
        "Whisper invents words on silence",
        "A near-silent buffer transcribes as 'Thank you.' Gate on RMS before calling the model.",
        tags=["stt", "gotcha"],
    )
    print(note.title)
    print(f"stamped {note.created_at}, tags {note.tags}")
$ uv run python labs/research_archive.py
Whisper invents words on silence
stamped 2026-08-14T21:18:07, tags ['stt', 'gotcha']

Your timestamp will read differently, obviously, and that is the entire point of the two lines doing the work. __post_init__ runs once per instance, right after the generated __init__ finishes, so every note gets the time it was actually created. Write created_at: str = datetime.now().isoformat() instead and the call happens once, when Python reads the class, freezing a single moment into every note the program will ever make. The empty string is not padding either: it is a sentinel meaning "nobody supplied one", and stage 2 depends on it. timespec="seconds" drops six digits of microsecond nobody reads while keeping ISO-8601 order, where a plain string sort is also a chronological sort.

▣ Build · stage 2 — load on open, save on add
import json
from dataclasses import asdict
from pathlib import Path

ARCHIVE_PATH = Path("glados/data/research_archive.json")


class ResearchArchive:
    def __init__(self, path: Path) -> None:
        self.path = path
        self.notes: list[ResearchNote] = self._load()

    def _load(self) -> list[ResearchNote]:
        if not self.path.exists():
            return []
        return [ResearchNote(**record) for record in json.loads(self.path.read_text())]

    def _save(self) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.path.write_text(json.dumps([asdict(n) for n in self.notes], indent=2))

    def add(self, note: ResearchNote) -> bool:
        if any(n.title == note.title for n in self.notes):
            return False
        self.notes.append(note)
        self._save()
        return True
$ uv run python labs/research_archive.py
stored: True
stored again: False
reopened from disk: 1 note, created 2026-08-14T21:18:07
$ head -8 glados/data/research_archive.json
[
  {
    "title": "Whisper invents words on silence",
    "content": "A near-silent buffer transcribes as 'Thank you.' Gate on RMS before calling the model.",
    "tags": [
      "stt",
      "gotcha"
    ],
    "created_at": "2026-08-14T21:18:07",

Two library calls carry the whole persistence layer. asdict walks a dataclass recursively and returns plain dicts, lists and strings, which is exactly what json.dumps accepts; ResearchNote(**record) splats a loaded dict back through the constructor. Neither _load nor _save names a single field, so adding author tomorrow means editing the dataclass and nothing else. Now watch the sentinel earn its keep: the saved dict already carries a created_at, so on reload the guard in __post_init__ sees a non-empty value and leaves the original time alone. Without that if, every restart would restamp the entire archive to the moment you opened it. The title check in add matters for the same reason: since _load runs first, a script that adds its notes on every run would otherwise duplicate them on every run, and a boolean return turns "already knew that" into an ordinary answer instead of an exception.

▣ Build · stage 3 — one filter across three fields
    def search(self, query: str) -> list[ResearchNote]:
        q = query.lower()
        return [
            n for n in self.notes
            if q in n.title.lower()
            or q in n.content.lower()
            or any(q in tag.lower() for tag in n.tags)
        ]
$ uv run python labs/research_archive.py
2 notes match 'silence':
  [2026-08-14T21:18:07] Whisper invents words on silence
  [2026-08-16T10:02:44] Silence threshold in the kitchen
0 notes match 'tts':

One comprehension, three places to look, and the lowering on both sides is what makes TTS, Tts and tts the same query. The any() clause reads "matches if any tag contains the query", so a note filed under ["stt", "gotcha"] comes back for either word. The second search returning nothing is the honest limit of substring matching, worth seeing early: stt and tts are one transposition apart and share no substring, so nothing here is clever enough to guess what you meant. There is no ranking either. Chapter 37 needed an ORDER BY because she has to pick one fact to put in a prompt; you get the list, and your eyes do the ranking.

▣ Build · stage 4 — pull the notebook in
# labs/research_archive.py — the last additions
EXPERIMENTS_DIR = Path("glados/data/experiments")


def import_experiments(archive: ResearchArchive, directory: Path = EXPERIMENTS_DIR) -> int:
    imported = 0
    for path in sorted(directory.glob("*.json")):
        exp = json.loads(path.read_text())
        note = ResearchNote(
            title=f"experiment: {exp['name']}",
            content=f"{exp['hypothesis']} | result: {exp['results'] or 'pending'}",
            tags=["experiment", exp["status"]],
            created_at=exp["created_at"],
            references=[str(path)],
        )
        if archive.add(note):
            imported += 1
    return imported


def main() -> None:
    archive = ResearchArchive(ARCHIVE_PATH)
    archive.add(ResearchNote(
        "Whisper invents words on silence",
        "A near-silent buffer transcribes as 'Thank you.' Gate on RMS before calling the model.",
        tags=["stt", "gotcha"],
    ))
    archive.add(ResearchNote(
        "Servo 4 stops five degrees short",
        "Right elbow reaches 175 deg on a 180 deg command. Treat 175 as its ceiling.",
        tags=["hardware", "calibration"],
    ))
    imported = import_experiments(archive)
    print(f"{len(archive.notes)} notes in the archive, {imported} imported this run")
    hits = archive.search("threshold")
    print(f"{len(hits)} notes match 'threshold':")
    for note in hits:
        print(f"  [{note.created_at}] {note.title}  {note.tags}")


if __name__ == "__main__":
    main()
$ uv run python labs/research_archive.py
5 notes in the archive, 3 imported this run
2 notes match 'threshold':
  [2026-07-02T14:08:31] experiment: silence threshold sweep  ['experiment', 'complete']
  [2026-08-16T10:02:44] Silence threshold in the kitchen  ['hardware', 'calibration']
$ uv run python labs/research_archive.py   # again, immediately
5 notes in the archive, 0 imported this run
2 notes match 'threshold':
  [2026-07-02T14:08:31] experiment: silence threshold sweep  ['experiment', 'complete']
  [2026-08-16T10:02:44] Silence threshold in the kitchen  ['hardware', 'calibration']

The lab notebook from chapter 25 has been writing one JSON file per experiment this whole time, and the sweeps from chapter 38 added more. Those files are structured already, so importing them is a rename of fields, not a parse. Two details make the import honest. The note carries the experiment's own created_at forward, so the guard from stage 1 declines to restamp it and the imported note lands on the timeline at the hour the work actually happened. And references holds the path to the full record, so the note is a pointer, not a second copy that can drift from the first. The second run is the payoff of the title check: it imports nothing, changes nothing, and one query now reaches across your experiments and your loose observations at once.

Why this works: one record type, two forms

The archive rests on a single idea. A ResearchNote in memory and its entry in the JSON file are the same data in two forms, and the dataclass machinery converts between them without being told the field names. Save is note to dict to text; load runs it backwards. That is why the class has no serialization code worth reviewing, and why a new field costs one line in one place. Compare it to the alternative you will meet in other people's projects: a to_dict and a from_dict, each listing every field by hand, silently disagreeing with each other about the field that got added last month.

The sentinel is the reusable half. Any record you both create fresh and reload from disk has fields that need a default on creation and must never be recomputed on load: a timestamp, an id, a version stamp. Putting the default in the field declaration cannot tell those two cases apart, because by the time __init__ runs, "not supplied" and "supplied as empty" look identical unless you make them look different. An empty string, or None, plus a guard in __post_init__, is how you write "compute this only when the caller had nothing." Chapter 25 used it for an experiment id; the same three lines here keep an imported note's history intact.

Both costs in this design are linear and both are fine. Search scans every note, and add rewrites the whole file. At a few hundred notes on an SSD, neither is measurable. When one of them does start to hurt, the exit is already familiar: the knowledge table from chapter 37 takes rows and indexes without you rewriting a line of ResearchNote. The record type is the durable part; storage is a decision you get to change later.

⚠ Worked failure — one typo, and every note disappears

The archive is a text file, and a text file invites hand-editing, which is a feature until you fix a note at midnight and type "tag" where the record says "tags". The next run does not lose one note:

$ uv run python labs/research_archive.py
Traceback (most recent call last):
  File "labs/research_archive.py", line 78, in <module>
    main()
  File "labs/research_archive.py", line 60, in main
    archive = ResearchArchive(ARCHIVE_PATH)
  File "labs/research_archive.py", line 26, in __init__
    self.notes: list[ResearchNote] = self._load()
  File "labs/research_archive.py", line 32, in _load
    return [ResearchNote(**record) for record in json.loads(self.path.read_text())]
  File "labs/research_archive.py", line 32, in <listcomp>
    return [ResearchNote(**record) for record in json.loads(self.path.read_text())]
TypeError: ResearchNote.__init__() got an unexpected keyword argument 'tag'

Follow the frames down and the diagnosis is in the last two. The failure is inside the comprehension, on the splat, which means the JSON parsed fine and the file is not corrupt; one dict in it simply has a key the constructor does not accept. The splat is a contract with the file, and ** forwards every key whether or not the record has somewhere to put it. What turns a one-key typo into a total outage is that _load is all or nothing: the comprehension raises on record three, so records one through two hundred never get built, and the archive that exists to make things findable finds nothing.

from dataclasses import fields

    def _load(self) -> list[ResearchNote]:
        if not self.path.exists():
            return []
        known = {f.name for f in fields(ResearchNote)}
        notes = []
        for i, record in enumerate(json.loads(self.path.read_text())):
            unknown = sorted(set(record) - known)
            if unknown:
                print(f"[warn] note {i} has unknown keys {unknown}; ignoring them")
            notes.append(ResearchNote(**{k: v for k, v in record.items() if k in known}))
        return notes
$ uv run python labs/research_archive.py
[warn] note 2 has unknown keys ['tag']; ignoring them
5 notes in the archive, 0 imported this run

fields() asks the dataclass what it accepts, so the filter cannot drift from the record. Chapter 22 met the same family of problem in her preferences file and answered it by merging unknown keys forward; here the answer is to warn loudly and keep reading, because the note's other four fields are still perfectly good and losing two hundred records to one mistyped key is the worse outcome by a wide margin. A loud warning also gets fixed. A silent drop does not.

Checkpoint, and a bench that answers back

✓ Checkpoint — what you can now do
  • I can explain what __post_init__ does that a field default cannot, and why the empty-string sentinel is what keeps a reloaded note's original timestamp.
  • I can name the two calls that carry the whole round trip, and say why neither _load nor _save mentions a field by name.
  • I can write a search across three fields in one comprehension, and state the two things it deliberately does not do.
  • I know why add checks the title before appending, and what a second run of the script would print without that check.
  • Shown a TypeError about an unexpected keyword argument on load, I can trace it to a single bad record and fix the loader so the other records survive.
  • I can import chapter 25's experiment files into the archive without any of them being restamped to today.
⚡ Exercises — try first, then reveal
Exercise 1 — a tag browser. Add tag_counts() returning every tag in the archive with how many notes carry it, most used first, and print it as a two-column list.

Counter(t for n in self.notes for t in n.tags) from collections does the flattening and the counting in one line, and .most_common() hands back the pairs already sorted. Print them with f"{tag:16} {count}" and you get the first real map of your own build: a run of the demo notes plus three imported experiments shows experiment 3, calibration 2, hardware 2, complete 2. Where the counts cluster is where the afternoons went.

Exercise 2 — take a note by voice. Wire a spoken "note this: the elbow ticks at full extension" through the behavior engine to archive.add(), then find it again with search("elbow") from a separate Python session.

Register a behavior whose trigger is the prefix note this: and whose handler builds a ResearchNote from the rest of the line, with tags=["voice"] and the title taken from the first six words. The separate session is the part that proves something: the note survived the process that made it, which a variable in the voice loop never does. Give it a priority below the safety reactions, since a dictation command should never win a race against a halt.

Exercise 3 — rank the results. Give search a score instead of a boolean: 3 for a title hit, 2 for a tag hit, 1 for a body hit, and return the matches sorted highest first.

Write _score(note, q) summing the three tests, then sorted((n for n in self.notes if _score(n, q)), key=..., reverse=True). Searching "servo" now puts "Servo 4 stops five degrees short" above a note that merely mentions servos in passing. That is chapter 37's ordering instinct without the database: the retrieval itself is unchanged, and a cheap score decides what your eye lands on first. Add a fourth point for a note less than a week old and you have recency weighting in one more line.

Look at what volume 5 built. A watchdog takes a heartbeat from the healthy path and restarts what dies, once per stall instead of forever. The first real hardware got measured against numbers written down in advance, so a servo five degrees short announces itself instead of being discovered during a demo. The seams between working modules have their own tests, and an empty suite can no longer call itself green. Every provider satisfies a contract Python enforces at construction, a harness times each test and records the failures as data, and her reactions moved out of an if/elif tower into priority-sorted behaviors. Her mood gained an intensity dial and a color an LED can show. Her hardware commands go through a queue where urgency beats arrival order, her vitals scroll through a bounded window that cannot hide a stall in an average, and everything you learned along the way is one query away. She is alive on the bench, and she can tell you how she is doing.

A bench is not a house. Volume 6 is the crossing: she scores her own ideas before acting on one, and a permission layer stands between her mouth and your shell so a sentence from a language model can never become an unreviewed command. Every motor and sensor goes behind a swappable driver, and the whole stack becomes a service that starts on boot and comes back after a power cut. Then the physical work, an enclosure and a wiring harness that survive being carried into another room, a manual she prints for herself, an architecture review of every seam, and an acceptance checklist you actually sign. The volume ends with a tuning pass in your own voice, a latency pass on the whole turn, and the record of the build packaged for someone who was not there. Clear a shelf.