GLaDOS Vol 3 · Her Craft
ch 22 / 99
Chapter 22

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.

◆ Note — two files in configs/, and the line between them

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

▣ Build · stage 1 — the contract, as a dict
# 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.

▣ Build · stage 2 — persist, and merge the overlay
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.

▣ Build · stage 3 — guard writes, greet with the result
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.

▣ Build · stage 4 — the module the rest of her imports
# 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.

⚠ Worked failure — crashes only for returning users

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

✓ Checkpoint — what you can now do
  • 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_preferences returns 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_PREFS instead 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] and prefs.get(key, fallback) and defend the choice now that the loader guarantees every default key exists.
⚡ Exercises — try first, then reveal
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.