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.
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
# 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.
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.
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.
# 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.
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
- 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
_loadnor_savementions 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
addchecks the title before appending, and what a second run of the script would print without that check. - Shown a
TypeErrorabout 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.
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.