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.
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
# 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.
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.
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.
# 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.
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
- 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
diffof 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.
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.