Proving the Tuning Instead of Guessing It
A sweep is five experiments in one sitting
Chapter 25 gave every tuning decision a record: a hypothesis fixed in advance, the parameters you changed, one result, one file. That fits a change you make once. Most of the tuning still ahead of you is not that. The silence threshold is a knob with a usable range, and so are Whisper's model size, the twenty turns of history the config caps at, and the mood decay from volume 2. Testing a knob means testing values, plural, in one sitting, on one microphone, against the same input.
Five separate Experiment records almost cover it. You write the same
hypothesis five times, you get five ids you now have to remember as a group, and nothing
on disk says the five ran back to back on the same hardware. The bigger loss is what a
couple of measurements hide. Try 0.01, try 0.02, watch accuracy fall, conclude that lower
is better, and set 0.005: you never learn it is the worst value of the three. Two points
give you a slope. A knob has a curve, with a different failure at each end and a plateau
somewhere in the middle, and you cannot see a curve two points at a time.
So the chapter's rule: one record per sweep, with the hypothesis written before the first measurement, every value appended along with the moment it was measured, and a filename that lets today's sweep sit beside next month's instead of replacing it.
The obvious next thought is to sweep two knobs together: five thresholds against five listen durations. That is 25 measurements for 10 values, and a jump between neighbouring cells can belong to either knob. Sweeping one at a time, fixing the winner, then moving on keeps every result attributable and every sitting short. The cost is interaction: if the best threshold really does depend on the listen duration, sequential sweeps will never notice. For a voice loop in one room that trade is comfortable, and the day it stops being, you will have the machinery to run the grid.
Stamp it, sweep it, keep it
# labs/prototype_run.py
import time
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class PrototypeRun:
name: str
hypothesis: str
config: dict
results: list = field(default_factory=list)
started_at: str = ""
def __post_init__(self) -> None:
if not self.started_at:
self.started_at = datetime.now().isoformat()
if __name__ == "__main__":
run = PrototypeRun(
name="silence threshold",
hypothesis="accuracy peaks near 0.01 and falls off on both sides",
config={"values": [0.005, 0.01, 0.02, 0.04, 0.08], "clips": 20},
)
print(f"{run.name}: {len(run.results)} results so far")
print(f"started_at: {run.started_at}")
$ uv run python labs/prototype_run.py
silence threshold: 0 results so far
started_at: 2026-08-18T09:41:22.318604
Two fields differ from chapter 25's Experiment, and both differences are
the chapter. results is a list, because a sweep produces a series where a
single change produced one outcome. And the list has to arrive through
field(default_factory=list): the factory is called once per instance, so
every run gets its own list, where a bare = [] would be one list shared by
every run ever constructed. Dataclasses refuse that outright, with the
ValueError you already met. hypothesis stays required and
stays singular, since a sweep asks one question across many values, and writing it now
is what stops you from discovering afterwards that you always expected whichever number
happened to win.
def record(self, step: str, value: float) -> None:
self.results.append({"step": step, "value": value, "at": time.time()})
run.record("threshold_0.005", 0.80)
run.record("threshold_0.01", 0.95)
print(f"recorded {len(run.results)} steps")
print(run.results[-1])
$ uv run python labs/prototype_run.py
recorded 2 steps
{'step': 'threshold_0.01', 'value': 0.95, 'at': 1755509046.712933}
Three keys, and the third is the one people leave out. With at on every
entry the list is a timeline: subtract the first stamp from the last and you know how
long the sweep took, and the gaps between neighbours tell you which value was slow to
measure. It is a float from time.time() rather than a readable string
because arithmetic is the whole reason it exists; the human-readable stamp already
lives once, on started_at. Recording the moment costs a function call
now and cannot be reconstructed at all later.
from pathlib import Path
import numpy as np
import soundfile as sf
CLIP_DIR = Path("glados/data/calibration")
def clip_accuracy(threshold: float) -> float:
clips = sorted(CLIP_DIR.glob("*.wav"))
correct = 0
for path in clips:
audio, _ = sf.read(path, dtype="float32")
rms = float(np.sqrt(np.mean(audio ** 2)))
heard_speech = rms >= threshold
is_speech = path.name.startswith("speech")
correct += int(heard_speech == is_speech)
return correct / len(clips)
def sweep(run: PrototypeRun) -> None:
for value in run.config["values"]:
accuracy = clip_accuracy(value)
run.record(f"threshold_{value}", accuracy)
print(f"threshold={value:<6} accuracy={accuracy:.2f}")
$ uv run python labs/prototype_run.py
threshold=0.005 accuracy=0.80
threshold=0.01 accuracy=0.95
threshold=0.02 accuracy=0.90
threshold=0.04 accuracy=0.80
threshold=0.08 accuracy=0.60
The clip set is twenty WAVs you record once with chapter 4's capture script: ten of
you talking normally, saved as speech_01.wav through
speech_10.wav, and ten of your empty room as silence_01.wav
onward. The label lives in the filename, so the right answer for every clip is decided
before any threshold is tried. Each clip's RMS is chapter 4's level check, unchanged;
the threshold turns that number into a verdict, and accuracy is the fraction of
verdicts that match the filename. Twenty clips means every result is a multiple of
0.05, a useful reminder of how coarse the instrument is.
These figures come off one bench run, one built-in laptop microphone, one quiet evening; yours will land elsewhere and your winning value may too. What repeats everywhere is the two-sided failure. Down at 0.005 the room's own hiss clears the bar and silence gets called speech. Up at 0.08 anything short of a raised voice is discarded. Between them sits a plateau where 0.01 and 0.02 both work, and its width matters as much as its peak: a knob with a wide safe band never needs revisiting, while a narrow one will betray you the week you move the microphone.
import json
from dataclasses import asdict
PROTOTYPE_DIR = Path("glados/data/prototypes")
def best(self) -> dict | None:
return max(self.results, key=lambda r: r["value"]) if self.results else None
def summary(self) -> dict:
top = self.best()
span = self.results[-1]["at"] - self.results[0]["at"] if self.results else 0.0
return {
"started": self.started_at[:16],
"steps": len(self.results),
"seconds": round(span, 1),
"best": top["step"] if top else None,
"value": top["value"] if top else None,
}
def save_run(run: PrototypeRun) -> Path:
PROTOTYPE_DIR.mkdir(parents=True, exist_ok=True)
slug = run.name.replace(" ", "-").lower()
stamp = run.started_at[:19].replace("-", "").replace(":", "")
path = PROTOTYPE_DIR / f"{slug}_{stamp}.json"
path.write_text(json.dumps(asdict(run), indent=2))
return path
def compare_runs(name: str) -> None:
slug = name.replace(" ", "-").lower()
for path in sorted(PROTOTYPE_DIR.glob(f"{slug}_*.json")):
s = PrototypeRun(**json.loads(path.read_text())).summary()
print(f"{s['started']} {s['best']:<16} {s['value']:.2f}"
f" ({s['steps']} values, {s['seconds']}s)")
def main() -> None:
run = PrototypeRun(
name="silence threshold",
hypothesis="accuracy peaks near 0.01 and falls off on both sides",
config={"values": [0.005, 0.01, 0.02, 0.04, 0.08],
"clips": 20, "mic": "laptop built-in"},
)
sweep(run)
print(f"saved {save_run(run)}")
compare_runs(run.name)
if __name__ == "__main__":
main()
$ uv run python labs/prototype_run.py
threshold=0.005 accuracy=0.80
threshold=0.01 accuracy=0.95
threshold=0.02 accuracy=0.90
threshold=0.04 accuracy=0.80
threshold=0.08 accuracy=0.60
saved glados/data/prototypes/silence-threshold_20260818T094122.json
2026-08-18T09:41 threshold_0.01 0.95 (5 values, 41.6s)
$ uv run python labs/prototype_run.py # three days later, on a USB microphone
threshold=0.005 accuracy=0.85
threshold=0.01 accuracy=0.95
threshold=0.02 accuracy=1.00
threshold=0.04 accuracy=0.90
threshold=0.08 accuracy=0.70
saved glados/data/prototypes/silence-threshold_20260821T201207.json
2026-08-18T09:41 threshold_0.01 0.95 (5 values, 41.6s)
2026-08-21T20:12 threshold_0.02 1.00 (5 values, 38.9s)
The second sweep is the payoff. A better microphone raised the whole curve and moved
the peak one step to the right, and the comparison says so in two lines read off disk,
with no appeal to what you think you remember about Tuesday. summary()
deliberately drops the results list: an index that reprinted every step
would be unreadable at twenty saved runs, while name, time, count and winner fit on a
line. And PrototypeRun(**json.loads(...)) is the same round trip chapter
25 relied on, working for the same reason: every field is a JSON-native type, and
__post_init__ leaves a loaded started_at alone because it is
already filled.
Why this works: one thing varies, and the rest is written down
A sweep is a controlled comparison, and control is what makes the ordering of five
numbers mean anything. The clip set is where the control lives. Because every threshold
faces the identical twenty files, the only difference between one measurement and the
next is the value you set, so a drop in accuracy has exactly one candidate explanation.
Sweep against live speech instead and each measurement changes two things at once, the
threshold and whatever you happened to say, leaving five numbers that cannot be ranked.
Everything you froze goes in config: the value list, the clip count, the
microphone. That dict is what tells you three months later whether an old run is
comparable to a new one.
The other choice doing quiet work is storing results as a list of small dicts instead of a mapping from value to accuracy. A dict would be tidier and would lose two things. It would lose order, which is the difference between a curve and a bag of numbers, and it would silently discard duplicates, so measuring 0.01 twice would leave one result and no hint that the two disagreed. A list keeps both measurements side by side, and repeated values are how you find out whether your instrument is stable at all. Order and repetition are cheap to keep and impossible to recover.
None of the machinery knows anything about audio. Swap clip_accuracy for a
function that transcribes a fixed clip with a given Whisper size, or one that asks the
language model a fixed question with a given history depth, and the sweep, the timeline,
the summary and the comparison all keep working. That is the reusable part: a
measurement function that takes one parameter and returns one number, and a record that
remembers the rest.
The natural first version names the file after the run, the way a document gets named:
def save_run(run: PrototypeRun) -> Path:
PROTOTYPE_DIR.mkdir(parents=True, exist_ok=True)
slug = run.name.replace(" ", "-").lower()
path = PROTOTYPE_DIR / f"{slug}.json" # one file per experiment
path.write_text(json.dumps(asdict(run), indent=2))
return path
$ ls glados/data/prototypes/ silence-threshold.json $ uv run python labs/prototype_run.py # after the second sweep 2026-08-21T20:12 threshold_0.02 1.00 (5 values, 38.9s)
Two sweeps ran, both printed five thresholds, both reported saving, and the comparison
offers one row. Nothing raised, nothing warned, and the missing row reads as "the
compare function is broken." The file convicts the writer instead: open
silence-threshold.json and started_at says Friday, with
Friday's accuracies under it. Same name, same path, and Path.write_text
truncates before writing, so the second sweep overwrote the first in place. The fix is
stage 4's filename, which appends the run's own started_at:
chronological, unique, readable in an ls. The rule underneath is one to
carry: a writer whose path comes only from a name can hold exactly one record, so when
the point of writing is comparison across time, time belongs in the path.
Checkpoint, and a curve you can defend
- I can say what a list of timestamped steps records that a single results dict cannot, and give a case where the difference changes the conclusion.
- I can explain why
resultsneedsdefault_factoryand why__post_init__leavesstarted_atuntouched on a loaded run. - I can defend the labelled clip set: what it holds constant, and what a sweep against live speech would actually be measuring.
- Given a five-point accuracy curve, I can name which failure produced each end and say why a wide plateau matters as much as the peak.
- I can name the two facts a saved run's filename must carry, and describe the silent loss when it carries only one.
- I can point at the one function I would replace to sweep model size or history depth with the same record.
Exercise 1 — sweep the transcriber. Keep every line of
PrototypeRun and replace the measurement: transcribe one fixed clip
with faster-whisper at tiny, base, and small,
and score each against a reference transcript you type by hand.
Write model_accuracy(size: str) -> float that loads the model,
transcribes the clip, and returns matching words over reference words; then set
config["values"] to the three names and record steps called
model_tiny and friends. The accuracies are yours, and on most
machines they climb with size while the seconds field in the summary
climbs faster. That column suddenly means something: the sweep that took under a
minute for thresholds takes several for models, and the timeline you have been
storing since stage 2 is what tells you so.
Exercise 2 — two metrics, one run. Record accuracy and
latency in the same sweep with prefixed step names, then teach best to
filter by prefix. Watch what it tells you about latency.
Record acc_small and ms_small side by side, then filter
before taking the maximum:
def best(self, prefix: str = "") -> dict | None:
matches = [r for r in self.results if r["step"].startswith(prefix)]
return max(matches, key=lambda r: r["value"]) if matches else None
Now run best("ms_") and read the answer carefully: it hands you the
slowest model, because "best" was hard-coded as "largest" the moment you reached
for max. For a metric where lower wins you need
min, so give the method a pick argument, or store
latency negated and never wonder again. Every metric needs a direction attached,
and the bug appears the first time you assume one.
Exercise 3 — measure your instrument. Sweep a single threshold, 0.01, five times in a row. Predict the five numbers before you run it, then repeat the exercise reading from the live microphone instead of the clips.
Against the clip set you get the same number five times, because the input never
changed and nothing in clip_accuracy is random. That is not a boring
result: it proves the measurement is repeatable, so any difference between two
sweeps belongs to the value you changed. Repeat it live, speaking a similar
sentence each time, and the five results scatter, which is the variance a clip set
exists to remove. Both experiments leave five entries in results,
since the list keeps duplicate steps that a dict would have collapsed to one.
Her settings are now defensible: a value in the config with a saved curve behind it and a date on the curve. What no sweep can tell you is what remains unbuilt, and by this point in the book that list is long and lives mostly in your head. Next chapter gives the project itself the same treatment, with milestones tied to chapter ranges so that "what is left" becomes something the code computes instead of something you try to recall.