GLaDOS Vol 4 · One System
ch 38 / 99
Chapter 38

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.

◆ Note — one knob at a time, and what that costs

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

▣ Build · stage 1 — a record that holds a series
# 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.

▣ Build · stage 2 — every result carries its moment
    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.

▣ Build · stage 3 — the sweep, measured against fixed clips
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.

▣ Build · stage 4 — summarize, save, compare
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.

⚠ Worked failure — the sweep I ran twice and can only see once

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

✓ Checkpoint — what you can now do
  • 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 results needs default_factory and why __post_init__ leaves started_at untouched 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.
⚡ Exercises — try first, then reveal
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.