GLaDOS Vol 6 · Ready for the House
ch 51 / 99
Chapter 51

The Scoring Function

Five fixes, one weekend

She works on the bench. That sentence hides a punch list, and yours probably looks like mine: she wakes when the dishwasher rattles, the right elbow buzzes at low speed, the first turn after boot takes seven seconds because a model is still loading, and she answers the same question about tomorrow's weather from scratch every time. None of it stops a demo. All of it makes her tiring to live with, and living with her is what this volume is for.

The hard part is not thinking of fixes. Sit down with the archive from chapter 50 open and you will produce eight candidates in twenty minutes, most of them plausible. The hard part is that you have a weekend and can do two. So you pick, and picking is where the engineering quietly goes wrong. Gut feel favours whatever you read about most recently. A conversation favours whoever argues longest. Either way, three weeks later you cannot reconstruct why the servo fix beat the caching fix, so when new measurements arrive the whole argument reopens from nothing.

So the rule that opens the volume: write down what you value as weighted criteria before you look at the candidates, then let an idea's rank be a function of those weights and the idea's own words. Today that function is fifteen lines and the file it writes records both halves, the ranking and the yardstick it was measured with. The point is not that the number is smart. The number is deliberately dumb. The point is that it cannot be talked out of what it found, and neither can you, three weeks later, when you open the file and read the weights you committed to.

◆ Note — she has a language model; why not let it rank them?

Since chapter 8 there has been a model on this machine that would happily order five ideas for you, and it is good at the other half of this job: ask it for candidate fixes and it will give you eight, including two you would not have thought of. Ranking is different work. Run the same five ideas past llama3.2:3b twice and you can get two different orders, with no way to ask which property earned the points, and nothing to diff next month when your priorities change. Generate with the model, decide with the function. The scorer below is auditable line by line, and that is the whole reason it exists.

Score it, rank it, keep the yardstick

▣ Build · stage 1 — an idea as a record
# labs/idea_scorer.py
from dataclasses import dataclass, field
from datetime import datetime


@dataclass
class Idea:
    title: str
    problem: str
    approach: str
    tags: list[str] = field(default_factory=list)
    score: float = 0.0
    created_at: str = ""

    def __post_init__(self) -> None:
        if not self.created_at:
            self.created_at = datetime.now().isoformat(timespec="seconds")


if __name__ == "__main__":
    idea = Idea(
        "Wake word latency",
        "The whole pipeline wakes for any noise in the kitchen",
        "Use a lightweight VAD model to gate the Whisper call",
        tags=["stt", "latency"],
    )
    print(f"{idea.title}: score={idea.score} tags={idea.tags}")
    print(f"captured {idea.created_at}")
$ uv run python labs/idea_scorer.py
Wake word latency: score=0.0 tags=['stt', 'latency']
captured 2026-08-19T22:41:06

Your timestamp will differ; every run in this chapter stamps the moment it happened. Six fields, and two of the choices matter. problem and approach stay separate because only one of them gets scored: the approach is the proposal, the problem is the thing you re-read in six weeks when you have forgotten what "wake word latency" meant on a Tuesday. And score defaults to 0.0 so an idea that has never been through the scorer is distinguishable from one that scored nothing.

field(default_factory=list) is the form to reach for whenever a dataclass field holds a list. Write tags: list[str] = [] and the decorator refuses to build the class at all, because a default is evaluated once when Python reads the class body, so every idea would share one list and appending a tag to one would tag them all. The factory is called once per instance instead. The empty-string default on created_at is the sentinel from chapter 25: an idea created here gets stamped, an idea rebuilt from disk arrives with its stamp already filled and __post_init__ leaves it alone.

▣ Build · stage 2 — the criteria, then the score
CRITERIA = {
    "lightweight": 3.0,   # has to fit beside everything else on the Pi
    "cached": 2.5,        # never pay twice for the same answer
    "gate": 2.5,          # cheap check before an expensive stage
    "smoothing": 2.0,     # quiet hardware in a quiet house
    "local": 1.0,         # nothing leaves the building
}
MAX_SCORE = 10.0


def score_idea(idea: Idea, criteria: dict[str, float]) -> float:
    total = 0.0
    text = idea.approach.lower()
    for criterion, weight in criteria.items():
        if criterion.lower() in text:
            total += weight
    return round(min(total, MAX_SCORE), 2)


if __name__ == "__main__":
    wake_word = Idea(
        "Wake word latency",
        "The whole pipeline wakes for any noise in the kitchen",
        "Use a lightweight VAD model to gate the Whisper call",
        tags=["stt", "latency"],
    )
    servo_jitter = Idea(
        "Servo 4 jitter",
        "The right elbow buzzes at low speed",
        "Apply exponential smoothing to angle commands",
        tags=["hardware"],
    )
    for candidate in (wake_word, servo_jitter):
        print(f"{candidate.title}: {score_idea(candidate, CRITERIA)}")
$ uv run python labs/idea_scorer.py
Wake word latency: 5.5
Servo 4 jitter: 2.0

Follow the arithmetic on the first one. The approach text contains "lightweight" (3.0) and "gate" (2.5); it does not contain "cached", "smoothing" or "local", so those weights contribute nothing, and 3.0 + 2.5 = 5.5. The servo idea, whose approach is "Apply exponential smoothing to angle commands", collects 2.0 and nothing else. Both sides of the comparison get lowered so Cached and cached are one criterion.

The five keywords are the entire opinion in this program. Each one stands in for a property this build actually values, and the weight says how much: fitting the Pi's memory budget beats keeping traffic in the house, because everything already runs in the house and nothing runs if it will not fit. The weights sum to 11.0, one more than MAX_SCORE, so the cap is reachable but only by an idea that satisfies nearly everything. Set the total below the cap and min() is decoration; set it far above and every serious idea pins at 10.0 and the ranking flattens.

▣ Build · stage 3 — the punch list, ranked
PUNCH_LIST = [
    Idea("Wake word latency",
         "The whole pipeline wakes for any noise in the kitchen",
         "Use a lightweight VAD model to gate the Whisper call",
         tags=["stt", "latency"]),
    Idea("Servo 4 jitter",
         "The right elbow buzzes at low speed",
         "Apply exponential smoothing to angle commands before the driver sees them",
         tags=["hardware"]),
    Idea("Cold model load",
         "The first turn after boot takes seven seconds",
         "Keep the transcription model cached in a resident process",
         tags=["stt", "startup"]),
    Idea("Repeated questions",
         "She rebuilds the same weather answer every morning",
         "Serve cached answers from a local table and gate the model call on a miss",
         tags=["llm", "latency"]),
    Idea("Hard questions",
         "The 3B model stalls on anything that needs real knowledge",
         "Send the hard ones to a hosted model and read the answer back",
         tags=["llm"]),
]


def rank(ideas: list[Idea], criteria: dict[str, float]) -> list[Idea]:
    for idea in ideas:
        idea.score = score_idea(idea, criteria)
    return sorted(ideas, key=lambda i: i.score, reverse=True)


if __name__ == "__main__":
    for position, idea in enumerate(rank(PUNCH_LIST, CRITERIA), start=1):
        print(f"{position}. {idea.score:>4}  {idea.title:<20} {idea.tags}")
$ uv run python labs/idea_scorer.py
1.  6.0  Repeated questions   ['llm', 'latency']
2.  5.5  Wake word latency    ['stt', 'latency']
3.  2.5  Cold model load      ['stt', 'startup']
4.  2.0  Servo 4 jitter       ['hardware']
5.  0.0  Hard questions       ['llm']

The weekend has two slots and the list just filled them. Read the bottom of it as carefully as the top: "Hard questions" scores 0.0, and that zero is a full sentence. The idea proposes sending your household's questions to somebody else's machine, and the criteria were written by someone who will not do that, so it collects none of the five weights. Nobody had to argue with it. The yardstick had already answered, back when it was five keywords in a dict and there were no candidates in the room to defend.

rank mutates each idea's score and returns a new sorted list, so the objects carry their own result and the ordering stays a separate thing you can recompute. sorted is stable, so two ideas tied at 2.5 come back in the order they were listed, not in an order that shuffles between runs. That stability is what lets you diff two rankings and read the difference as a decision instead of noise.

▣ Build · stage 4 — save the ranking with the yardstick
# labs/idea_scorer.py — full file
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path

RESULTS_PATH = Path("glados/data/scored_ideas.json")
MAX_SCORE = 10.0

CRITERIA = {
    "lightweight": 3.0,
    "cached": 2.5,
    "gate": 2.5,
    "smoothing": 2.0,
    "local": 1.0,
}


@dataclass
class Idea:
    title: str
    problem: str
    approach: str
    tags: list[str] = field(default_factory=list)
    score: float = 0.0
    created_at: str = ""

    def __post_init__(self) -> None:
        if not self.created_at:
            self.created_at = datetime.now().isoformat(timespec="seconds")


PUNCH_LIST = [
    Idea("Wake word latency",
         "The whole pipeline wakes for any noise in the kitchen",
         "Use a lightweight VAD model to gate the Whisper call",
         tags=["stt", "latency"]),
    Idea("Servo 4 jitter",
         "The right elbow buzzes at low speed",
         "Apply exponential smoothing to angle commands before the driver sees them",
         tags=["hardware"]),
    Idea("Cold model load",
         "The first turn after boot takes seven seconds",
         "Keep the transcription model cached in a resident process",
         tags=["stt", "startup"]),
    Idea("Repeated questions",
         "She rebuilds the same weather answer every morning",
         "Serve cached answers from a local table and gate the model call on a miss",
         tags=["llm", "latency"]),
    Idea("Hard questions",
         "The 3B model stalls on anything that needs real knowledge",
         "Send the hard ones to a hosted model and read the answer back",
         tags=["llm"]),
]


def score_idea(idea: Idea, criteria: dict[str, float]) -> float:
    total = 0.0
    text = idea.approach.lower()
    for criterion, weight in criteria.items():
        if criterion.lower() in text:
            total += weight
    return round(min(total, MAX_SCORE), 2)


def rank(ideas: list[Idea], criteria: dict[str, float]) -> list[Idea]:
    for idea in ideas:
        idea.score = score_idea(idea, criteria)
    return sorted(ideas, key=lambda i: i.score, reverse=True)


def save_ranking(ideas: list[Idea], criteria: dict[str, float], path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "scored_at": datetime.now().isoformat(timespec="seconds"),
        "criteria": criteria,
        "ideas": [asdict(i) for i in ideas],
    }
    path.write_text(json.dumps(payload, indent=2))


def load_ranking(path: Path) -> tuple[list[Idea], dict[str, float]]:
    payload = json.loads(path.read_text())
    return [Idea(**record) for record in payload["ideas"]], payload["criteria"]


def main() -> None:
    ideas = load_ranking(RESULTS_PATH)[0] if RESULTS_PATH.exists() else list(PUNCH_LIST)
    ranked = rank(ideas, CRITERIA)
    save_ranking(ranked, CRITERIA, RESULTS_PATH)
    reloaded, criteria = load_ranking(RESULTS_PATH)
    print(f"{len(ranked)} ideas scored against {len(criteria)} criteria")
    print(f"top: {reloaded[0].title} ({reloaded[0].score}), first written {reloaded[0].created_at}")


if __name__ == "__main__":
    main()
$ uv run python labs/idea_scorer.py
5 ideas scored against 5 criteria
top: Repeated questions (6.0), first written 2026-08-19T22:41:06
$ head -12 glados/data/scored_ideas.json
{
  "scored_at": "2026-08-19T22:41:06",
  "criteria": {
    "lightweight": 3.0,
    "cached": 2.5,
    "gate": 2.5,
    "smoothing": 2.0,
    "local": 1.0
  },
  "ideas": [
    {
      "title": "Repeated questions",
$ uv run python labs/idea_scorer.py   # after raising "lightweight" to 4.0
5 ideas scored against 5 criteria
top: Wake word latency (6.5), first written 2026-08-19T22:41:06

One weight moved by a point and the weekend's first job changed. That is weighted scoring doing exactly what it promises: the keywords choose who is in the running, the weights choose who wins. If a single point can flip your top two, the two ideas were close, and the file now says so in a form you can read next month.

Storing criteria beside the ideas is the part I would not skip. A score of 6.0 on its own is a number with no meaning attached; 6.0 next to the dict that produced it is a decision with its reasoning stapled on, and diff across two saved files shows you the weight that moved and the order that moved with it. Note also what the second run did not do. The ideas came back from disk with their original created_at intact, because the loaded record carries a non-empty stamp and the guard in __post_init__ declines to overwrite it, so re-scoring an old list never makes it look freshly invented.

Why this works: the judgment lives in the dict

The scorer never understands an idea. It asks one question per criterion, "does this word appear in the approach text", and adds a weight when the answer is yes. Every bit of intelligence in the ranking sits in the criteria dict you wrote, which is exactly where you can see it, argue about it, put it under version control and change it on purpose. Swap in a model that reads the ideas and forms an opinion and the intelligence moves somewhere you cannot inspect, and a surprising result stops being a question you can answer.

Because every idea meets the same dict, the scores are comparable, and comparability is the property the whole exercise is buying. Ranking is a pure function of the criteria and the approach text: same inputs, same order, every time, on any machine. That is the same instinct as chapter 42, where hardware passed or failed against tolerances written down before the servo moved. Fix the yardstick first and a measurement can disappoint you. Fix it afterwards and it can only agree with you.

Substring matching is a crude proxy for "this idea has the property I want", and its crudeness is the honest trade. A one-line rule you can hold in your head means a surprising score sends you to look at your criteria, not into a black box. It also means the failures are the kind you can predict, and the most common one is sitting in the criteria above waiting for the right sentence.

⚠ Worked failure — the criterion that scored a word inside a word

A sixth idea arrives from the same evening's notes, and it satisfies none of the five criteria: no caching, nothing lightweight, no smoothing, no gating, no local table. Score it anyway.

new = Idea(
    "False wake fires",
    "She wakes for the dishwasher",
    "Mitigate the false fires by raising the silence threshold, no new model",
)
print(f"score: {score_idea(new, CRITERIA)}")
for criterion, weight in CRITERIA.items():
    if criterion in new.approach.lower():
        print(f"  matched {criterion} ({weight})")
$ uv run python labs/idea_scorer.py
score: 2.5
  matched gate (2.5)

No traceback, no warning, and an idea that qualifies for nothing lands mid-table, tied with the cold-start fix and above the servo work. The second loop is what convicts it: the criterion that matched is gate, and the word gate is not in that sentence. It is inside a word in that sentence. "Mitigate" ends in g-a-t-e, and "gate" in text asks about characters, not words, so the substring test is perfectly correct and completely wrong. Once you see it, the same trap is everywhere: "delegate", "aggregate", "investigate", "gateway".

import re


def score_idea(idea: Idea, criteria: dict[str, float]) -> float:
    total = 0.0
    text = idea.approach.lower()
    for criterion, weight in criteria.items():
        if re.search(rf"\b{re.escape(criterion)}\b", text):
            total += weight
    return round(min(total, MAX_SCORE), 2)
$ uv run python labs/idea_scorer.py
score: 0.0

\b is a word boundary: a position where a letter or digit sits next to something that is not one. \bgate\b needs a boundary on both ends, and in "Mitigate" the left side is i, so there is no match and the false 2.5 disappears. re.escape is there because criteria are data you might edit later, and a criterion like c++ would otherwise be read as a regex and raise on the plus. The fix costs you something real: \blocal\b no longer matches "locally" and \bcached\b no longer matches "caching", so criteria become base words you spell the way you will write them. Pay it. A scorer that quietly credits ideas for letters they happen to contain is worse than no scorer, because it produces a number that looks like a measurement.

Checkpoint, and the gate between her mouth and your shell

✓ Checkpoint — what you can now do
  • I can compute an idea's score by hand from a criteria dict and its approach text, and say which weights did not land and why.
  • I can explain what field(default_factory=list) does per instance, and what the decorator does when handed a bare list default instead.
  • I can say what the min(total, MAX_SCORE) cap accomplishes, and how to choose weights so the cap is reachable without flattening the ranking.
  • I know why the saved file holds the criteria as well as the scores, and what a diff of two runs tells me that the numbers alone do not.
  • Shown an idea that scored points it should not have, I can find the criterion that matched inside a longer word and fix the test with \b.
  • I can state the one thing this ranking guarantees that a conversation cannot: same criteria and same text, same order, every run.
⚡ Exercises — try first, then reveal
Exercise 1 — score the tags too. Write feasibility(idea, tag_weights) that sums a weight per tag, defaulting an unknown tag to zero, and print each punch-list idea as title, score, feasibility.

One line does the work: round(sum(tag_weights.get(t, 0.0) for t in idea.tags), 2), where .get with a default is the same missing-key habit the mood table needed in chapter 12. Score {"llm": 1.0, "stt": 1.5, "hardware": 3.0} and the servo work climbs, because a fix that needs a soldering iron and a printed part is a different kind of expensive from one that needs an afternoon of Python. Print both columns and resist merging them into one total: two numbers that disagree tell you more than one number that split the difference.

Exercise 2 — capture ideas by voice. Add a behavior whose trigger is the prefix idea:, build an Idea from the rest of the sentence, append it to the saved file, and show it appearing in the next ranking.

The handler splits the spoken line on the first comma into problem and approach, titles the idea from its first four words, loads the file with load_ranking, appends, re-ranks and saves. Say "idea: she talks over the kettle, gate the reply on a loudness check" and watch it come back scored 2.5 on the next run. Register it below the safety reactions in the priority order from chapter 46, since dictation should never win a race against a stop command, and keep the score out of her spoken reply. She does not need to know how her ideas rated.

Exercise 3 — make the yardstick argue back. Write explain(idea, criteria) that returns the list of criteria that matched with their weights, then print the top three ideas with their reasons instead of their totals.

[(c, w) for c, w in criteria.items() if re.search(rf"\b{re.escape(c)}\b", idea.approach.lower())] is the body, and the printout reads "Repeated questions 6.0: cached 2.5, gate 2.5, local 1.0". Now the ranking defends itself without you re-deriving the arithmetic, and one thing becomes obvious that the totals hid: an idea whose reasons are three small weights is a different animal from one whose reasons are a single large one. Store the reasons in the JSON alongside the score and a month-old ranking still explains itself.

The list is ranked and the top item is a caching layer that gates a model call. Before any of it gets built, though, there is a more urgent gate to install. She already turns spoken sentences into commands, and a command she misheard is one step from a shell that will run it without asking. Next chapter puts a blocked-text filter and a permission table between her mouth and your machine, so every action has to clear one gate before anything happens, and a refusal comes back with a reason attached.