Her Preferences
Twenty-one chapters in, she still forgets your name
Last chapter she started improving her own answers from a rated log. Impressive, and oddly impersonal: she can curate her best exchanges and still not know what to call you, how long you like her replies, or which voice you want in your kitchen at seven in the morning. Those choices exist today as constants scattered through the labs, or as a dict you rebuild at every launch. A dict is the right container (chapter 2 made dictionaries the home for her state), but a dict lives in the process, and the process ends. Preferences that reset on restart are not preferences; they are questions she asks you every day.
The obvious fix is to json.dump the dict to a file and
json.load it back, and the obvious fix hides the most common config
bug in existence. A bare json.load returns exactly what is on disk.
The day you add a new preference key, every file saved before that day is missing
it, and the first square-bracket lookup raises KeyError. Testing never
catches this: your test file was saved five minutes ago, by the current code. The files that break are the old ones: the crash
is reserved for whoever has been running her the longest.
So the design, in one rule: the defaults dict is the contract. Every preference has a safe value there, a saved file is only an overlay merged on top of it, and no key that lacks a default is ever written. Build those three pieces (loader, saver, guard) and an old save file gains new keys silently, forever.
configs/ now holds two identities. personality.json
(chapter 12) is who she is: base prompt, moods, tone modifiers. The new
configs/preferences.json is who you are: name, verbosity,
voice. The boundary earns its keep the day you share the project: personality
ships in git for anyone building their own GLaDOS; preferences stay on your
machine and out of version control. Neither file gets secrets: readable JSON
ends up in screenshots and backups, so keys and passwords live in environment
variables.
Defaults, overlay, guard
# labs/preferences.py
DEFAULT_PREFS = {
"user_name": "Test Subject",
"greeting_enabled": True,
"response_length": "brief",
"voice": "piper",
"speaking_rate": 1.0,
}
def load_preferences() -> dict:
return DEFAULT_PREFS.copy()
prefs = load_preferences()
print(f"{len(prefs)} preferences, every one a default")
print(f"name={prefs['user_name']} voice={prefs['voice']}")
$ uv run python labs/preferences.py
5 preferences, every one a default
name=Test Subject voice=piper
Five keys, each with a value she can run on before you have chosen anything. The
.copy() is load-bearing: returning DEFAULT_PREFS
directly would hand every caller the same object, so the first
prefs["voice"] = "f5" anywhere would rewrite the defaults for the
rest of the process. Chapter 2 called that the aliasing bug; here it
would corrupt the one dict whose job is to stay pristine. Each load gets its own copy, and the contract stays clean.
import json
from pathlib import Path
PREFS_PATH = Path("configs/preferences.json")
def load_preferences(path: Path) -> dict:
if path.exists():
saved = json.loads(path.read_text())
return {**DEFAULT_PREFS, **saved}
return DEFAULT_PREFS.copy()
def save_preferences(prefs: dict, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(prefs, indent=2) + "\n")
prefs = load_preferences(PREFS_PATH)
save_preferences(prefs, PREFS_PATH)
print(f"wrote {len(prefs)} keys to {PREFS_PATH}")
# simulate a save file from an older version: one key, four missing
PREFS_PATH.write_text('{"user_name": "Chell"}')
prefs = load_preferences(PREFS_PATH)
print(f"name={prefs['user_name']} rate={prefs['speaking_rate']}")
$ uv run python labs/preferences.py
wrote 5 keys to configs/preferences.json
name=Chell rate=1.0
The merge is the chapter: {**DEFAULT_PREFS, **saved} lays the
defaults down first and the saved file over them, so the result always carries
every key the code might read. The simulation at the end is the proof. That
one-key file stands in for a config written before speaking_rate
existed, and the load returns the saved name and the default rate with no
special-case code. On the saving side, mkdir(parents=True,
exist_ok=True) means a fresh clone creates configs/ on first
run, and indent=2 keeps the file editable by hand, since you are
allowed to open your own preferences in a text editor.
def set_preference(prefs: dict, key: str, value: object) -> bool:
if key not in DEFAULT_PREFS:
return False
prefs[key] = value
return True
def greeting(prefs: dict) -> str:
if not prefs.get("greeting_enabled", True):
return ""
name = prefs.get("user_name", "Test Subject")
return f"Oh. It's you, {name}. Welcome back."
prefs = load_preferences(PREFS_PATH)
print(greeting(prefs))
for key, value in [("response_length", "verbose"), ("colour", "amber")]:
ok = set_preference(prefs, key, value)
print(f"{key}: {'updated' if ok else 'rejected (no such preference)'}")
save_preferences(prefs, PREFS_PATH)
$ uv run python labs/preferences.py
Oh. It's you, Chell. Welcome back.
response_length: updated
colour: rejected (no such preference)
The guard checks DEFAULT_PREFS, not prefs, and the
difference matters. A merged dict can carry orphan keys inherited from older
files; checking membership there would let a typo like "colour"
slip in as a new key that nothing ever reads, and the file would drift one
misspelling at a time. The contract is the defaults, so writes are validated
against the defaults. greeting keeps chapter 12's
.get fallbacks even though the merge should make them redundant:
this function will eventually be handed dicts from tests and event-bus payloads
that never went through the loader, and a second net costs nothing.
# labs/preferences.py — full file
import json
from pathlib import Path
PREFS_PATH = Path("configs/preferences.json")
DEFAULT_PREFS = {
"user_name": "Test Subject",
"greeting_enabled": True,
"response_length": "brief",
"voice": "piper",
"speaking_rate": 1.0,
}
def load_preferences(path: Path) -> dict:
if path.exists():
saved = json.loads(path.read_text())
return {**DEFAULT_PREFS, **saved}
return DEFAULT_PREFS.copy()
def save_preferences(prefs: dict, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(prefs, indent=2) + "\n")
def set_preference(prefs: dict, key: str, value: object) -> bool:
if key not in DEFAULT_PREFS:
return False
prefs[key] = value
return True
def greeting(prefs: dict) -> str:
if not prefs.get("greeting_enabled", True):
return ""
name = prefs.get("user_name", "Test Subject")
return f"Oh. It's you, {name}. Welcome back."
def main() -> None:
prefs = load_preferences(PREFS_PATH)
line = greeting(prefs)
if line:
print(line)
print(f"replies={prefs['response_length']} voice={prefs['voice']} "
f"rate={prefs['speaking_rate']}")
save_preferences(prefs, PREFS_PATH)
if __name__ == "__main__":
main()
$ uv run python labs/preferences.py
Oh. It's you, Chell. Welcome back.
replies=verbose voice=piper rate=1.0
Note what survived on disk across three runs: the name from the simulated old
file, the verbosity you set in stage 3, defaults for everything else. Every
function returns a value and main owns the printing, the same split
the whole book runs on, because the point of this module is to be imported. The
voice loop reads response_length when it builds her prompt, the
speech step will read voice and speaking_rate, and the
save-after-change habit means a crash never costs more than the current session's
edits.
Why this works: three cases, one merge
Dict unpacking builds a new dict by inserting items left to right, and a repeated
key keeps the later value. That single behavior sorts every key into one of three
outcomes. A key in both dicts takes the saved value: your customization wins over
the default. A key only in the defaults keeps the default: an old file is filled in
without ceremony, and this case is the entire reason the merge exists. A key only
in the saved file survives as an orphan the code never reads, harmless on load,
and set_preference exists so no new orphans are ever created. Loader
fills, guard filters; between them the file can neither starve the program nor
pollute it.
The pattern generalizes well beyond her. Git resolves your identity by layering
repository config over global over system, and your editor stacks workspace
settings the same way. In every case the built-in defaults are the base layer
and each file above it may be sparse, which is what
lets a file age gracefully: a config that only records what you changed can never
be missing something the program needs. The forward rule that keeps it true here is
one commit-sized habit: the same change that reads a new key adds its default to
DEFAULT_PREFS. Do that, and version upgrades stop being events.
The first-draft loader everyone writes returns the file verbatim. Suppose stage
2's merge was never written, and the file on disk is a legitimate save from an
older version, back when she had two preferences:
{"user_name": "Chell", "voice": "piper"}.
def load_preferences(path: Path) -> dict:
if path.exists():
return json.loads(path.read_text()) # BUG: the file, verbatim
return DEFAULT_PREFS.copy()
$ uv run python labs/preferences.py
Oh. It's you, Chell. Welcome back.
Traceback (most recent call last):
File "/home/you/GladOS/labs/preferences.py", line 46, in <module>
main()
File "/home/you/GladOS/labs/preferences.py", line 41, in main
print(f"replies={prefs['response_length']} voice={prefs['voice']} "
KeyError: 'response_length'
Read the two lines above the traceback together, because they are the whole
lesson. The greeting printed: greeting looks up its keys with
.get and fallbacks, so it sailed over the missing data. The summary
line died: it uses square brackets, and response_length is simply
not in a two-key dict. Same dict, two lookup styles, one crash. And notice when
this fires: never on a fresh install (no file, so defaults load), never in your
testing (your file is always current), only on a machine where an old save
predates a new key. The merge closes the hole at the one place all reads flow
through, so no caller has to remember to be careful.
Checkpoint, saved to disk
- I can write the overlay merge from memory and say which of the three key cases (both, defaults-only, saved-only) each side of it decides.
- I can explain why
load_preferencesreturns a copy when no file exists, and what the aliasing bug does to the defaults if it does not. - I know why the unknown-key guard validates against
DEFAULT_PREFSinstead of the loaded dict, and what drifts if it checks the wrong one. - I can stage the old-save-file crash on demand, and I can say why ordinary testing structurally never finds it.
- Given any lookup in this codebase, I can choose between
prefs[key]andprefs.get(key, fallback)and defend the choice now that the loader guarantees every default key exists.
Exercise 1 — factory reset. Add
reset_preferences(path): delete the saved file and return a fresh
copy of the defaults. Prove it worked by printing whether the file exists
before and after.
path.unlink(missing_ok=True) deletes without raising when the
file is already gone, then return DEFAULT_PREFS.copy(). The
missing_ok flag makes the function idempotent: calling reset
twice is as safe as calling it once, the property chapter 19 taught you to
demand from anything that might re-fire. Run it and watch
path.exists() flip from True to
False while the returned dict greets Test Subject again.
Exercise 2 — show me my settings. Write
describe_preferences(prefs) returning one aligned line per key,
so "what are you set to?" has an answer a human can scan.
Measure first, format second: width = max(len(k) for k in
prefs), then build lines with f"{k.ljust(width)} :
{v}" and join on newlines. Return the string and let the caller
print it; wired into the voice loop later, the same string can be spoken
or logged. The aligned colon column keeps the dump readable at a glance,
and keeps working when the dict grows to twenty keys.
Exercise 3 — type-check the writes. Extend
set_preference to also reject a value whose type disagrees with
the default's type. Then try setting greeting_enabled to
"yes" and watch it bounce.
Compare against the contract: expected =
type(DEFAULT_PREFS[key]), then reject when
isinstance(value, expected) is false. The string
"yes" is refused for a bool default, and a later
if prefs["greeting_enabled"]: stays honest, since the string
"no" would count as true. One wrinkle to know about:
isinstance(True, int) holds in Python, so a bool can sneak
into an integer preference. None of this dict's keys hit that case; file
it away for the day one does.
She remembers your name, your verbosity, your voice settings, and a restart no longer erases you. But listen to what she does with that voice: raw model output is full of ellipses, all-caps words, and stray markdown that read fine on screen and sound wrong out of a speaker. Next chapter puts a text cleaner between the model and Piper, so what she says stops sounding like something she is reading.