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

A Mood With a Dial

Her mood has no volume knob

Chapter 24 gave her a mood that moves on its own: a keyword scan reads your tone, a transition table turns that sentiment into the next mood, and a counter walks her back to neutral after a run of quiet turns. It works. Sit with it for a week and two things start to grate. The first is that every mood arrives at full strength. One sarcastic remark and one sustained argument both leave her exactly "hostile", because hostile is a word and words have no magnitude. The second is that she forgets. Close the process, reopen it, and she greets you neutral, however the last session ended.

Under both sits the same weakness. The mood is a bare string on an object, and any line in the project can write to it. state.mood = "angry" assigns cleanly even though nothing in the transition table has ever produced that word. state.mood = "curius" assigns just as cleanly. Neither raises. The typo surfaces later, somewhere else, when the code that turns her mood into a color for an LED looks up a key that was never in the palette, and by then the line that wrote it has been off the stack for ten minutes.

So the rule this chapter keeps: mood is a name plus a number between 0 and 1, and one guarded method is the only way either of them changes. The name is the kind of mood, still drawn from the fixed set chapter 24's table can produce. The number is how much of it she is currently feeling. Every write goes through a door that validates the name, pulls the number back into range, and writes down the move; nothing else touches the fields. Then the pair goes to disk, so tomorrow starts where tonight ended.

◆ Note — where the second number comes from

Kind and degree arrive from different places, and that is the useful part. The transition table answers "which mood next" from the sentiment label. Degree answers "how much" from something else entirely: how many turns in a row carried the same signal, how many keywords fired in one sentence, how long it has been since anything moved her. Chapter 24's exercise on scoring each sentiment category by match count produces exactly such a number. Keep the two axes separate and you can improve your reading of degree without touching a single table entry.

One door for every change

▣ Build · stage 1 — a palette that is also the list of legal moods
# labs/mood_state.py
from dataclasses import dataclass

EMOTION_COLORS = {
    "neutral":    "#4a9eff",
    "satisfied":  "#39ff14",
    "curious":    "#ffcc00",
    "hostile":    "#ff4444",
    "melancholy": "#9966cc",
}


@dataclass
class MoodState:
    mood: str = "neutral"
    intensity: float = 0.5

    def display_color(self) -> str:
        return EMOTION_COLORS.get(self.mood, "#ffffff")


if __name__ == "__main__":
    state = MoodState()
    print(f"{state.mood:10} {state.display_color()} @ {state.intensity}")
    state.mood = "curious"      # nothing stops this yet
    print(f"{state.mood:10} {state.display_color()} @ {state.intensity}")
    state.mood = "curius"       # nor this
    print(f"{state.mood:10} {state.display_color()} @ {state.intensity}")
$ uv run python labs/mood_state.py
neutral    #4a9eff @ 0.5
curious    #ffcc00 @ 0.5
curius     #ffffff @ 0.5

One dict does two jobs. Its values are the colors, and its keys are the set of moods that legally exist, so validation and rendering can never disagree about what a mood is. Adding "amused" to the palette adds it to the vocabulary in the same edit. The third line of output is the deliberate hole in this stage: a typo assigned without complaint, and .get with a white default kept the lookup from raising, so the only evidence is an LED quietly showing the wrong color. Stage 2 closes the hole at the source.

▣ Build · stage 2 — the guarded transition
from datetime import datetime
from dataclasses import dataclass, field


def clamp01(value: float) -> float:
    return max(0.0, min(1.0, value))


@dataclass
class MoodState:
    mood: str = "neutral"
    intensity: float = 0.5
    history: list = field(default_factory=list)

    def transition(self, new_mood: str, intensity: float = 0.5) -> bool:
        if new_mood not in EMOTION_COLORS:
            return False
        self.history.append({
            "from": self.mood,
            "to": new_mood,
            "at": datetime.now().isoformat(timespec="seconds"),
        })
        self.mood = new_mood
        self.intensity = clamp01(intensity)
        return True


if __name__ == "__main__":
    state = MoodState()
    print(state.transition("curious", 0.7), state.mood, state.intensity)
    print(state.transition("angry", 0.9), state.mood, state.intensity)
    print(state.transition("hostile", 1.4), state.mood, state.intensity)
    print("recorded:", len(state.history))
$ uv run python labs/mood_state.py
True curious 0.7
False curious 0.7
True hostile 1.0
recorded: 2

Read the middle line first. "angry" is not in the palette, so the guard returns False before anything is written: the mood stays curious, the intensity stays 0.7, and no phantom entry lands in the history. Returning a bool instead of failing silently means the caller can log the rejection, which is how you find out that a behavior handler has been asking for a mood you never defined. The last line pairs with it: three calls, two records, because history counts accepted moves. And clamp01 is chapter 27's trick at a different scale. There it kept a servo angle inside 0 to 180 degrees at the point of use; here it keeps a mood inside 0.0 to 1.0, so 1.4 is stored as 1.0 and the LED code downstream never has to wonder.

▣ Build · stage 3 — deepen a mood without changing it
from labs.emotional_state import MOOD_TRANSITIONS

NUDGE_STEP = 0.2

    def nudge(self, delta: float) -> float:
        self.intensity = clamp01(self.intensity + delta)
        return self.intensity


def apply_sentiment(state: MoodState, sentiment: str) -> None:
    target = MOOD_TRANSITIONS.get(state.mood, {}).get(sentiment)
    if target is None:
        return
    if target == state.mood:
        state.nudge(NUDGE_STEP)
    else:
        state.transition(target)


if __name__ == "__main__":
    state = MoodState()
    for sentiment in ["negative", "negative", "negative", "positive"]:
        apply_sentiment(state, sentiment)
        print(f"{sentiment:9} -> {state.mood:10} @ {state.intensity:.2f}")
$ uv run python labs/mood_state.py
negative  -> hostile    @ 0.50
negative  -> hostile    @ 0.70
negative  -> hostile    @ 0.90
positive  -> neutral    @ 0.50

This is the behavior the discrete version could not express. Chapter 24's table maps hostile plus a negative signal back to hostile, and treating that as a transition means three insults in a row leave her exactly as hostile as one did. Reading the same entry as "same kind, more of it" turns the second and third into nudge calls, and the number climbs while the name holds still. The last line shows the other half staying intact: kindness moves hostile to neutral, and a change of kind resets the dial to the default 0.5, because how deeply she felt the old mood says nothing about the new one. Note that nudge writes no history entry. History is a record of moves between moods, and a run of nudges is one mood getting louder.

A mood that survives the power button

▣ Build · stage 4 — save the pair, restore it, distrust the file
import json
from pathlib import Path

STATE_PATH = Path("glados/data/mood_state.json")

    def save(self, path: Path) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps({
            "mood": self.mood,
            "intensity": round(self.intensity, 3),
            "history": self.history[-10:],
        }, indent=2))

    @classmethod
    def load(cls, path: Path) -> "MoodState":
        if not path.exists():
            return cls()
        data = json.loads(path.read_text())
        mood = data.get("mood", "neutral")
        if mood not in EMOTION_COLORS:
            mood = "neutral"
        return cls(
            mood=mood,
            intensity=clamp01(float(data.get("intensity", 0.5))),
            history=data.get("history", []),
        )

load revalidates everything, and that is not paranoia about your own save. The JSON file is outside the door. You will hand-edit it while debugging, a half-written file will survive a power cut mid-write, and a future version of the palette will drop a mood some old file still names. Any of those hands load a value the guard would have rejected, so the guard runs again on the way in: unknown mood falls back to neutral, intensity goes through the same clamp, and a missing file returns plain defaults instead of raising. The [-10:] slice on save is the bounded-log habit from chapter 29 applied to emotions: a month of running produces thousands of transitions, and the ten most recent are the ones that explain how she got here.

▣ Build · stage 5 — the color reaches a pin
# labs/mood_state.py — the demo, at the bottom of the file
DEVICES: dict = {
    "eye_led": {"pin": 12, "type": "rgb", "enabled": False, "color": "#000000"},
}


def simulate_set_led_color(config: dict, device_name: str, color: str) -> None:
    if device_name not in config:
        print(f"Device not found: {device_name}")
        return
    config[device_name]["color"] = color
    print(f"[SIM] {device_name} set to {color}")


def main() -> None:
    state = MoodState.load(STATE_PATH)
    print(f"restored:  {state.mood} @ {state.intensity:.2f}")
    for sentiment in ["negative", "negative", "curious", "positive"]:
        apply_sentiment(state, sentiment)
        simulate_set_led_color(DEVICES, "eye_led", state.display_color())
        print(f"           {sentiment:9} -> {state.mood:10} @ {state.intensity:.2f}")
    state.save(STATE_PATH)
    print(f"saved {len(state.history)} transitions to {STATE_PATH}")


if __name__ == "__main__":
    main()
$ uv run python labs/mood_state.py
restored:  neutral @ 0.50
[SIM] eye_led set to #ff4444
           negative  -> hostile    @ 0.50
[SIM] eye_led set to #ff4444
           negative  -> hostile    @ 0.70
[SIM] eye_led set to #ffcc00
           curious   -> curious    @ 0.50
[SIM] eye_led set to #39ff14
           positive  -> satisfied  @ 0.50
saved 3 transitions to glados/data/mood_state.json
$ head -8 glados/data/mood_state.json
{
  "mood": "satisfied",
  "intensity": 0.5,
  "history": [
    {
      "from": "neutral",
      "to": "hostile",
      "at": "2026-08-22T21:14:07"
    },

Run it a second time and the first line reads restored: satisfied @ 0.50, because the session before it ended pleased. Your timestamps will differ from the ones above, and so will the mood you find on disk. The LED writes follow chapter 27's pattern exactly: a device dict with a pin and a current value, a function that mutates the entry and announces it, no GPIO library anywhere in this file. When the light ring arrives in volume 7 the real driver replaces the body of simulate_set_led_color and nothing else moves, because everything above the call already speaks in hex colors. Three transitions and one nudge produced four LED writes: the repaint follows every update, since intensity will eventually control brightness even when the color holds.

Why this works: two axes and one clamp

The design rests on a single claim you can check by reading four lines: after any successful transition, the object is in a state the rest of the system knows how to handle. The mood came out of EMOTION_COLORS, so display_color cannot miss. The intensity came out of clamp01, so anything reading it as a fraction, a brightness, a prompt modifier, gets a number in the range it expects. Illegal values are not caught later by defensive checks scattered through the callers; they are refused at the one place that writes.

Splitting kind from degree is what keeps the state small. Five moods with a continuous dial covers everything from barely curious to intensely curious with five names and one float. Encode the same expressiveness as discrete states and you get "slightly_curious", "very_curious", a transition table that grows as the square of your ambition, and a palette entry for each. The dial also gives you an axis chapter 24's counter cannot supply: a mood can fade smoothly toward zero instead of expiring on a turn count, which is the first exercise below.

One deliberate choice deserves an argument, because it goes the other way from the mood guard. An unknown mood is refused; an out-of-range intensity is quietly corrected. That asymmetry is about what a caller can do with the answer. A wrong mood name is a bug in your code with no sensible correction available, so refusing it and returning False is the honest reply. An intensity of 1.4 usually means an accumulator ran past the top of its range, and the sensible reading is "as intense as it gets". If you would sooner hear about it, raise ValueError inside clamp01 before clamping; the call sites do not change.

⚠ Worked failure — she never feels anything strongly

The clamp is two nested calls, and the nesting order is easy to get backwards while typing quickly. Both versions look plausible on the page:

def clamp01(value: float) -> float:
    return min(0.0, max(1.0, value))     # BUG: min and max swapped
$ uv run python labs/mood_state.py
restored:  neutral @ 0.50
[SIM] eye_led set to #ff4444
           negative  -> hostile    @ 0.00
[SIM] eye_led set to #ff4444
           negative  -> hostile    @ 0.00
[SIM] eye_led set to #ffcc00
           curious   -> curious    @ 0.00
[SIM] eye_led set to #39ff14
           positive  -> satisfied  @ 0.00
saved 3 transitions to glados/data/mood_state.json

No traceback, and every mood still transitions correctly, so the table and the guard are both fine. The clue is in the first line: restored: reports 0.50, the dataclass default, and it is the one number in the run that never went through the clamp. Everything that did is zero. Trace the swapped version by hand with 0.7 and it falls out: max(1.0, 0.7) is 1.0, and min(0.0, 1.0) is 0.0. Now try 1.4, or -0.2, or 0.5. Every input reaches at least 1.0 at the inner call and is then floored to 0.0 by the outer one, so the function is a constant expensively disguised as a clamp. The one-line confirmation is print(clamp01(0.7)), and the property to remember is that a correct clamp returns its argument unchanged whenever the argument is already legal.

Checkpoint, and commands competing for one body

✓ Checkpoint — what you can now do
  • I can say what the keys of EMOTION_COLORS are used for besides rendering, and what adding one entry adds to the system.
  • I can explain why a repeated sentiment calls nudge while a changed one calls transition, and why only the second writes history.
  • I can name three ways the JSON file can hand load a value the guard would have rejected, and point at the lines that catch each.
  • I can defend refusing an unknown mood while silently clamping an out-of-range intensity, and say what changes if I would rather raise.
  • Shown a run where every intensity prints 0.00, I can find the swapped clamp from the one number that escaped it and confirm it with a single call.
⚡ Exercises — try first, then reveal
Exercise 1 — let it fade. Add decay(rate) that lowers intensity by rate each tick and, when it reaches zero, returns her to neutral at 0.5. Start from hostile at 0.6 and print four ticks at 0.25 a tick.

The body is two statements: self.intensity = clamp01(self.intensity - rate), then if self.intensity == 0.0 and self.mood != "neutral": self.transition("neutral"). Going home through transition rather than by assignment is the point of the exercise, since the return to neutral belongs in the history like any other move. Four ticks print hostile @ 0.35, hostile @ 0.10, neutral @ 0.50, neutral @ 0.25. Compare that with chapter 24's counter, which drops her the whole way on a fixed turn: this version lets a deep mood take longer to leave than a shallow one, at the cost of a tick source you have to call.

Exercise 2 — brick up the back door. Make state.mood = "curius" impossible instead of merely discouraged, then show the error it now produces.

Rename the field to _mood and add a read-only property: @property over def mood(self) -> str: return self._mood, with transition writing self._mood. Assignment now raises AttributeError: property 'mood' of 'MoodState' object has no setter, at the exact line that tried it, which beats a white LED ten minutes later. The tradeoff is that the dataclass constructor takes _mood as its keyword, so load needs updating too. Run the old typo line and read the traceback; that traceback is the whole return on the change.

Exercise 3 — make the dial visible. Scale the LED color by intensity so a mood at 0.2 glows dim and the same mood at 1.0 burns. Print the hex at four intensities.

Split the hex into three bytes with int(color[1:3], 16) and its two siblings, multiply each by the intensity, and reassemble with f"#{r:02x}{g:02x}{b:02x}". Hostile at 1.0, 0.7, 0.4 and 0.2 gives #ff4444, #b22f2f, #661b1b, #330d0d: the same hue, four brightnesses, and now the second axis is something you can see across the room instead of a float in a log. Real LEDs are not linear in perceived brightness, so a low setting will look brighter than the arithmetic suggests; the gamma correction that fixes it arrives with the ring itself.

Her mood is now a quantity: validated, persistent, and already producing hardware writes. That last part is the problem the next chapter opens with. An LED repaint, an arm move, and a halt from a collision sensor all arrive as commands for one body, and right now whichever one is issued first is the one that runs first. Chapter 48 puts a priority queue between her decisions and her hardware, so urgency decides the order and a halt never waits behind a color change.