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.
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
# 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.
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.
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
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.
# 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.
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
- I can say what the keys of
EMOTION_COLORSare used for besides rendering, and what adding one entry adds to the system. - I can explain why a repeated sentiment calls
nudgewhile a changed one callstransition, and why only the second writes history. - I can name three ways the JSON file can hand
loada 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.
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.