Sign-Off Is a Number
Six subsystems, all green, and no answer
This volume has been about the parts of shipping that are invisible from inside the code: a scoring function that ranks what to build next, a permission table that refuses by default, one failure behaviour shared by every driver, a service unit regenerated from configuration, a wiring manifest that validates itself, a manual generated from the same registry the dispatcher reads. Every check passes. She wakes on her name, answers, moves the arm. And there is still nothing on disk that answers the question anybody will actually ask you: is she finished?
The usual answer is a demo. You run her five or six times in front of someone, it goes well, you call it done. Two bills come due later. Six weeks on, when a housemate asks whether the wake word is reliable, the honest reply is "it felt quick", which reports your mood on the afternoon you tested. Worse is what happens the first time a number disappoints you: a word error rate of fourteen percent becomes "close enough for a house" precisely because you found out it was fourteen. A standard agreed after the result is the result in a standard's clothing.
So the rule this chapter builds and then obeys: acceptance is a numbered list of criteria, each carrying a limit, a direction and a flag saying whether it blocks; the whole list is fixed before the first measurement, and the verdict is computed from it. Chapter 42 did this for one servo, writing the expected value and the tolerance down before touching the protractor. The same discipline over an assembled system needs two more things: a score that can be compared across runs, and a fingerprint of the criteria themselves, so that later you can show the bar stayed where you put it.
One laptop running her the way volume 4 assembled her, an ESP32 on the USB link from chapter 40, and the SG90 servo carrying the arm. Every measured value printed in this chapter came off that bench on one afternoon with those parts and those models. Latency depends on your CPU, error rate on your microphone and your accent, and arm travel on the individual servo in your hand. Yours will land somewhere else. Nothing here asks you to reproduce these numbers; it asks you to produce your own and write them next to the limits you set in advance.
A criterion is a limit with a direction
# labs/acceptance.py
from dataclasses import dataclass
DIRECTIONS = {"<=": lambda measured, limit: measured <= limit,
">=": lambda measured, limit: measured >= limit}
@dataclass
class Criterion:
id: str
statement: str
unit: str
limit: float
direction: str
blocking: bool = True
measured: float | None = None
note: str = ""
def __post_init__(self) -> None:
if self.direction not in DIRECTIONS:
raise ValueError(f"{self.id}: direction must be one of {sorted(DIRECTIONS)}")
@property
def passed(self) -> bool:
if self.measured is None:
return False
return DIRECTIONS[self.direction](self.measured, self.limit)
if __name__ == "__main__":
ac = Criterion("AC-02", "transcription word error rate", "%", 10.0, "<=")
print(ac.id, ac.statement, ac.direction, ac.limit, ac.unit)
print(" before measuring:", ac.passed)
ac.measured = 11.4
print(" at 11.4 percent: ", ac.passed)
ac.measured = 6.9
print(" at 6.9 percent: ", ac.passed)
$ uv run python -m labs.acceptance
AC-02 transcription word error rate <= 10.0 %
before measuring: False
at 11.4 percent: False
at 6.9 percent: True
Half of what you want to promise about an assistant gets better as the number falls:
latency, error rate, failures per hundred replies. The other half gets better as it
climbs: facts recalled, commands matched, patterns refused. Storing the comparison as
a string in direction keeps both kinds in one list, and
__post_init__ is what makes that safe, because a typo like
"=<" raises the moment the plan is built instead of quietly answering
False forever. passed stays a computed property for the
reason chapter 42 gave it one: a stored boolean can drift away from the numbers that
produced it, and a criterion whose measured is still None
has to report failure, because unmeasured and passing must never print the same word.
import hashlib
import json
def acceptance_plan() -> list[Criterion]:
return [
Criterion("AC-01", "wake word to first spoken word", "ms", 2000.0, "<="),
Criterion("AC-02", "transcription word error rate", "%", 10.0, "<="),
Criterion("AC-03", "replies rated in character", "of 5", 4.0, ">=", blocking=False),
Criterion("AC-04", "synthesis failures in 20 replies", "failures", 0.0, "<="),
Criterion("AC-05", "facts recalled after a restart", "facts", 12.0, ">="),
Criterion("AC-06", "commands reaching the right handler", "commands", 8.0, ">="),
Criterion("AC-07", "blocked patterns refused", "patterns", 6.0, ">="),
Criterion("AC-08", "arm travel between its stops", "deg", 160.0, ">="),
]
def plan_id(criteria: list[Criterion]) -> str:
bar = [[c.id, c.limit, c.direction, c.blocking] for c in criteria]
return hashlib.sha256(json.dumps(bar).encode()).hexdigest()[:12]
def print_plan(criteria: list[Criterion]) -> None:
print(f"Acceptance plan {plan_id(criteria)}")
for c in criteria:
rule = "blocking" if c.blocking else "exception allowed"
print(f" {c.id} {c.statement:<36}{c.direction} {c.limit:<8.1f}{c.unit:<10}{rule}")
if __name__ == "__main__":
plan = acceptance_plan()
print_plan(plan)
met = sum(1 for c in plan if c.passed)
print(f" {met} of {len(plan)} criteria met before anything is measured")
$ uv run python -m labs.acceptance
Acceptance plan c28f93f99a1b
AC-01 wake word to first spoken word <= 2000.0 ms blocking
AC-02 transcription word error rate <= 10.0 % blocking
AC-03 replies rated in character >= 4.0 of 5 exception allowed
AC-04 synthesis failures in 20 replies <= 0.0 failures blocking
AC-05 facts recalled after a restart >= 12.0 facts blocking
AC-06 commands reaching the right handler >= 8.0 commands blocking
AC-07 blocked patterns refused >= 6.0 patterns blocking
AC-08 arm travel between its stops >= 160.0 deg blocking
0 of 8 criteria met before anything is measured
Read those eight lines as a document: the promise you are making about the machine, in numbers, on one screen, reviewable by somebody who has never seen the code. Two of the choices deserve an argument now, while arguing is free. AC-03 is the only criterion with a human in the loop and the only one marked non-blocking, because a listener's average rating over twenty turns is a real signal and a soft one, and treating it as hard would hand one person's Tuesday a veto over the build. AC-08 asks for 160 degrees of travel and not the 180 printed on the servo's bag, because the arm was designed around the travel the part actually delivers, and that decision belongs in the plan where it can be read. The twelve hex characters on the first line are a fingerprint of the promise, hashed from every id, limit, direction and blocking flag, and stamped on every run to come. The last line is the honest starting position: nothing has been measured, so nothing has passed.
Eight numbers, and where each one comes from
import contextlib
import io
import math
import re
import time
from collections.abc import Callable
from pathlib import Path
from labs.hardware_link import open_link
from labs.hardware_tests import measure_servo_travel
from labs.safety import BLOCKED_PATTERNS, SafetyFilter, level_of
from labs.system_config import CONFIG_PATH, load_config
from labs.test_harness import TestHarness
from labs.wire_providers import build_core
ACCEPTANCE = Path("glados/data/acceptance")
WAKE_CLIPS = ACCEPTANCE / "wake"
def measure_wake_latency() -> tuple[float, str]:
clips = sorted(WAKE_CLIPS.glob("*.wav"))
if len(clips) < 20:
raise ValueError(f"{len(clips)} wake clips on disk, the plan asks for 20")
core = build_core(load_config(CONFIG_PATH))
samples = []
with contextlib.redirect_stdout(io.StringIO()):
for clip in clips[:20]:
started = time.perf_counter()
core.run_turn(str(clip))
samples.append((time.perf_counter() - started) * 1000)
samples.sort()
p95 = samples[math.ceil(0.95 * len(samples)) - 1]
return p95, f"{len(samples)} clips, slowest {samples[-1]:.0f} ms"
def refusal_case(pattern: str) -> Callable[[], None]:
def case() -> None:
text = f"GLaDOS, {pattern} the archive for me."
allowed, reason = SafetyFilter().check(text, "play_music", level_of("voice"))
assert not allowed, f"{pattern!r} passed the filter: {reason}"
case.__name__ = "refuses_" + re.sub(r"\W+", "_", pattern.strip())
return case
def measure_safety_refusals() -> tuple[float, str]:
runner = TestHarness("blocked patterns")
with contextlib.redirect_stdout(io.StringIO()):
for pattern in BLOCKED_PATTERNS:
runner.test(refusal_case(pattern))
counts = runner.summary()
return float(counts["passed"]), f"{counts['passed']}/{counts['total']} refused"
def measure_arm_travel(link) -> tuple[float, str]:
test = measure_servo_travel(link)
return test.measured, test.note
Three probes, three different places a number is allowed to come from. AC-01 times a
real turn through the real engines and takes the 95th percentile of twenty runs, which
with twenty samples means the second slowest: an assistant is judged on its bad
moments, and an average would bury them. AC-07 delegates to the runner from chapter
45, one case per blocked pattern, and converts its summary into a count, so
6.0 is arithmetic over six assertions instead of a claim. AC-08 has no
electronic instrument at all, so it reuses the servo probe unchanged and inherits its
protractor prompts. The rule holding all three together is chapter 42's: no probe may
compute its result from the criterion it is filling in. A measurement you could have
predicted from the target is a target with extra steps.
# labs/acceptance.py — the rest of the sheet
import sqlite3
from glados.knowledge import DB_PATH, search
from labs.automation import ACTION_REGISTRY, RULES, match_rules
from labs.wire_providers import AUDIO_OUT
TRANSCRIPTS = ACCEPTANCE / "transcripts.json"
REPLY_LINES = ACCEPTANCE / "reply_lines.json"
REPLY_RATINGS = ACCEPTANCE / "reply_ratings.json"
RECALL_TOPICS = ACCEPTANCE / "recall_topics.json"
SPOKEN = Path(AUDIO_OUT)
# Eight utterances and the handler each one is supposed to reach.
DISPATCH_CASES = [
("GLaDOS, lights off please.", "turn_off_lights"),
("Turn the lights off in here.", "turn_off_lights"),
("Set timer for five minutes.", "set_timer"),
("GLaDOS, set timer, ten minutes.", "set_timer"),
("Play music, something loud.", "play_music"),
("Would you play music for once?", "play_music"),
("Lights off, then play music.", "turn_off_lights"),
("Set timer while you play music.", "set_timer"),
]
def measure_word_error_rate() -> tuple[float, str]:
references = json.loads(TRANSCRIPTS.read_text())
core = build_core(load_config(CONFIG_PATH))
wrong = total = 0
with contextlib.redirect_stdout(io.StringIO()):
for clip, said in references.items():
expected = said.lower().split()
heard = core.process(str(ACCEPTANCE / clip)).lower().split()
total += len(expected)
wrong += sum(1 for i, word in enumerate(expected)
if i >= len(heard) or heard[i].strip(".,?!") != word.strip(".,?!"))
wrong += max(0, len(heard) - len(expected))
if total == 0:
raise ValueError(f"{TRANSCRIPTS} holds no reference words")
return 100.0 * wrong / total, f"{wrong} wrong of {total} words"
def rate_replies_by_ear() -> tuple[float, str]:
ratings = json.loads(REPLY_RATINGS.read_text())["ratings"]
if len(ratings) < 20:
raise ValueError(f"{len(ratings)} ratings on file, the plan asks for 20")
below = sum(1 for r in ratings if r < 4)
return sum(ratings) / len(ratings), f"{len(ratings)} turns, {below} rated below 4"
def measure_synthesis_failures() -> tuple[float, str]:
lines = json.loads(REPLY_LINES.read_text())["replies"]
core = build_core(load_config(CONFIG_PATH))
written = 0
with contextlib.redirect_stdout(io.StringIO()):
for line in lines:
SPOKEN.unlink(missing_ok=True)
try:
core.speak(line)
except Exception:
continue
if SPOKEN.exists() and SPOKEN.stat().st_size > 0:
written += 1
return float(len(lines) - written), f"{written}/{len(lines)} files written"
def measure_recall_after_restart() -> tuple[float, str]:
topics = json.loads(RECALL_TOPICS.read_text())["topics"]
conn = sqlite3.connect(DB_PATH) # a new connection is the restart
try:
found = sum(1 for topic in topics if search(conn, [topic], limit=1))
finally:
conn.close()
return float(found), f"{found}/{len(topics)} after restart"
def measure_command_dispatch() -> tuple[float, str]:
matched = 0
for said, expected in DISPATCH_CASES:
hits = match_rules(said, RULES)
reached = hits[0]["action"] if hits else None
if reached == expected and reached in ACTION_REGISTRY:
matched += 1
return float(matched), f"{matched}/{len(DISPATCH_CASES)} matched"
Five probes, and not one of them invents its own number. AC-02 pushes recorded clips back through the same transcription path a live turn uses and counts word-level disagreements against a reference file you typed once, in both directions, so an invented extra word costs as much as a missing one. AC-03 measures nothing at all: it averages a ratings file a person filled in, because there is no instrument for whether a reply sounded like her. AC-04 deletes the output file before every line and then asks whether a non-empty one appeared, which is the only definition of "the voice worked" that does not take the engine's word for it. AC-05 opens a fresh connection to the database and looks for topics she was told about days ago, since a new connection is what a restart looks like from the data's side. AC-06 walks eight utterances past the matcher and counts only those that reach the expected handler and find it registered, so a rule that matches into an empty registry scores nothing. Two of these load real engines and take minutes; three read a file and finish instantly. The plan does not care which, and neither does the report.
def probes(link) -> dict[str, Callable[[], tuple[float, str]]]:
return {
"AC-01": measure_wake_latency,
"AC-02": measure_word_error_rate,
"AC-03": rate_replies_by_ear,
"AC-04": measure_synthesis_failures,
"AC-05": measure_recall_after_restart,
"AC-06": measure_command_dispatch,
"AC-07": measure_safety_refusals,
"AC-08": lambda: measure_arm_travel(link),
}
def run_plan(criteria: list[Criterion],
registered: dict[str, Callable[[], tuple[float, str]]]) -> None:
for c in criteria:
probe = registered.get(c.id)
if probe is None:
c.note = "no measurement registered"
continue
try:
c.measured, c.note = probe()
except Exception as exc:
c.measured, c.note = None, f"measurement error: {exc}"
The dictionary is the seam. Criteria are argued over and then frozen; probes get
rewritten as a subsystem matures, and swapping one is an edit to a single value here.
Building the dictionary inside a function lets the hardware probe close over an open
serial link while the software probes take no arguments, so every entry has the same
callable signature no matter what it needs underneath. The
except is the same trade chapter 45 made in its own runner: a probe that
raises becomes a recorded failure instead of a stack trace that abandons the other
seven measurements. Note the assignment order in that branch. Setting
measured back to None is not tidiness, it is the difference
between a criterion that failed to measure and a criterion that measured well, and
the next stage is where forgetting it would cost you.
The verdict, and a run that refuses to round up
def verdict(criteria: list[Criterion]) -> str:
unmet = [c for c in criteria if not c.passed]
if any(c.blocking for c in unmet):
return "NOT ACCEPTED"
if unmet:
return f"ACCEPTED WITH {len(unmet)} EXCEPTION" + ("S" if len(unmet) > 1 else "")
return "ACCEPTED"
def report(criteria: list[Criterion]) -> None:
met = sum(1 for c in criteria if c.passed)
print(f"\nAcceptance run against plan {plan_id(criteria)}")
for c in criteria:
flag = "PASS" if c.passed else "FAIL"
shown = "not measured" if c.measured is None else f"{c.measured:.1f}"
print(f" [{flag}] {c.id} {c.statement:<36}{shown:>12} {c.unit:<10}"
f"{c.direction} {c.limit:<8.1f}{c.note}")
print(f"\n Score: {met} of {len(criteria)} criteria met "
f"({100 * met / len(criteria):.1f}%)")
for c in criteria:
if not c.passed:
rule = "blocking" if c.blocking else "exception allowed"
print(f" Unmet: {c.id} ({rule})")
print(f" Verdict: {verdict(criteria)}")
$ uv run python -m labs.acceptance # measured on the bench, yours will vary
Measuring 8 criteria. AC-08 will ask you for a protractor reading.
protractor reading at commanded 0 deg: 6
protractor reading at commanded 180 deg: 173
Acceptance run against plan c28f93f99a1b
[PASS] AC-01 wake word to first spoken word 1420.0 ms <= 2000.0 20 clips, slowest 1655 ms
[FAIL] AC-02 transcription word error rate 11.4 % <= 10.0 8 wrong of 70 words
[FAIL] AC-03 replies rated in character 3.8 of 5 >= 4.0 20 turns, 3 rated below 4
[PASS] AC-04 synthesis failures in 20 replies 0.0 failures <= 0.0 20/20 files written
[PASS] AC-05 facts recalled after a restart 12.0 facts >= 12.0 12/12 after restart
[PASS] AC-06 commands reaching the right handler 8.0 commands >= 8.0 8/8 matched
[PASS] AC-07 blocked patterns refused 6.0 patterns >= 6.0 6/6 refused
[PASS] AC-08 arm travel between its stops 167.0 deg >= 160.0 6 to 173 deg
Score: 6 of 8 criteria met (75.0%)
Unmet: AC-02 (blocking)
Unmet: AC-03 (exception allowed)
Verdict: NOT ACCEPTED
Six of eight, and the machine says NOT ACCEPTED while you stand there watching it work
beautifully. That gap is the point of the exercise. AC-02 missed by 1.4 percentage
points, which is exactly the size of gap that talks you into rounding, because the
demo felt fine and the deadline is Thursday. The report gives that temptation nothing
to hold: the limit was written before the reading, the reading is printed beside it,
and the verdict is a function of the two. The score line prints one decimal on
purpose, since :.0% would turn seven of eight into a tidy 88 percent and
lose the half a reader deserves to see.
from datetime import datetime
REPORT = Path("docs/acceptance.json")
BENCH = "laptop CPU, ESP32 DevKit v1, SG90 arm servo"
def save_run(criteria: list[Criterion], path: Path = REPORT, bench: str = BENCH) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
runs = json.loads(path.read_text()) if path.exists() else []
met = sum(1 for c in criteria if c.passed)
runs.append({
"recorded_at": datetime.now().isoformat(timespec="seconds"),
"plan": plan_id(criteria),
"bench": bench,
"score": {"met": met, "total": len(criteria), "verdict": verdict(criteria)},
"criteria": [
{"id": c.id, "statement": c.statement, "unit": c.unit, "limit": c.limit,
"direction": c.direction, "blocking": c.blocking,
"measured": c.measured, "passed": c.passed, "note": c.note}
for c in criteria
],
})
path.write_text(json.dumps(runs, indent=2))
def main() -> None:
criteria = acceptance_plan()
print(f"Measuring {len(criteria)} criteria. AC-08 will ask you for a protractor reading.")
with open_link() as link:
run_plan(criteria, probes(link))
report(criteria)
save_run(criteria)
print(f"Run appended to {REPORT}")
if __name__ == "__main__":
main()
$ uv run python -m labs.acceptance # after switching the transcription model
Acceptance run against plan c28f93f99a1b
[PASS] AC-01 wake word to first spoken word 1980.0 ms <= 2000.0 20 clips, slowest 2240 ms
[PASS] AC-02 transcription word error rate 6.9 % <= 10.0 5 wrong of 70 words
[FAIL] AC-03 replies rated in character 3.8 of 5 >= 4.0 20 turns, 3 rated below 4
[PASS] AC-04 synthesis failures in 20 replies 0.0 failures <= 0.0 20/20 files written
[PASS] AC-05 facts recalled after a restart 12.0 facts >= 12.0 12/12 after restart
[PASS] AC-06 commands reaching the right handler 8.0 commands >= 8.0 8/8 matched
[PASS] AC-07 blocked patterns refused 6.0 patterns >= 6.0 6/6 refused
[PASS] AC-08 arm travel between its stops 167.0 deg >= 160.0 6 to 173 deg
Score: 7 of 8 criteria met (87.5%)
Unmet: AC-03 (exception allowed)
Verdict: ACCEPTED WITH 1 EXCEPTION
One edit produced that: the transcription component in the config moved from the small model to the next size up, which is a value change in one file and no code at all. The error rate came down to 6.9 percent, and the tail went up by more than half a second, from 1420 to 1980 milliseconds against a limit of 2000. Both numbers are on the same sheet, so the trade is visible instead of felt, and AC-01 is now close enough to its limit that it belongs on the list of things to watch. The verdict names its own remaining hole. She is accepted with one exception, and the exception is printed every single time the report runs, so nobody has to remember it and nobody gets to call this eight of eight.
[
{
"recorded_at": "2026-08-22T16:41:07",
"plan": "c28f93f99a1b",
"bench": "laptop CPU, ESP32 DevKit v1, SG90 arm servo",
"score": { "met": 7, "total": 8, "verdict": "ACCEPTED WITH 1 EXCEPTION" },
"criteria": [
{
"id": "AC-02",
"statement": "transcription word error rate",
"unit": "%",
"limit": 10.0,
"direction": "<=",
"blocking": true,
"measured": 6.9,
"passed": true,
"note": "5 wrong of 70 words"
}
]
}
]
Both runs carry the fingerprint c28f93f99a1b, and that string is the
quietest important thing in the file. It hashes only the parts of the plan that
constitute the promise: every id, limit, direction and blocking flag. Change a
statement's wording and it holds steady. Loosen AC-02 to 12 percent and it changes,
which turns the oldest trick in engineering acceptance into a visible edit. Two runs
with the same fingerprint are comparable; two runs with different ones measured
different promises, whatever the score says.
Why this works: the order of two writes
Everything the plan protects comes from one piece of sequencing. The limit is written to the file at time A, the measurement at time B, and A comes before B. Reverse that order and every part of the machinery still runs: the dataclass holds the same fields, the report prints the same columns, the verdict function returns the same strings. What disappears is the only thing that made the output evidence. A limit chosen after the reading encodes what the system did, and a report full of those is an elaborate way of writing down what happened and calling it a standard.
The blocking flag is what keeps that discipline survivable. A plan where every criterion is a veto gets loosened wholesale the first time something soft fails, because the only way to move is to lower a bar. A plan with two tiers has a third answer available: record the miss, print it in the verdict line, ship anyway with the exception attached to the artifact. That is a decision somebody made and can be held to, and it stays separate from the ones that are still absolute. The score, meanwhile, is deliberately weak on its own. Six of eight tells you how far along you are; the two named unmet lines under it tell you whether either of them is allowed to be missing.
Measuring all eight takes a while, so you add a fix-and-remeasure loop to
main and skip the four minutes AC-01 spends on twenty clips, since the
only thing you changed was the transcription model:
def main() -> None:
criteria = acceptance_plan()
with open_link() as link:
run_plan(criteria, probes(link))
report(criteria)
input("fix what failed, then press enter to measure again: ")
run_plan(criteria, probes(link)) # BUG: the same objects, measured twice
report(criteria)
$ uv run python -m labs.acceptance # second pass, AC-01 commented out of probes()
Acceptance run against plan c28f93f99a1b
[PASS] AC-01 wake word to first spoken word 1420.0 ms <= 2000.0 no measurement registered
[PASS] AC-02 transcription word error rate 6.9 % <= 10.0 5 wrong of 70 words
[FAIL] AC-03 replies rated in character 3.8 of 5 >= 4.0 20 turns, 3 rated below 4
Score: 7 of 8 criteria met (87.5%)
Unmet: AC-03 (exception allowed)
Verdict: ACCEPTED WITH 1 EXCEPTION
The verdict is the one you were hoping for and the top line is nonsense: a PASS at
1420 milliseconds sitting next to a note saying nothing measured it. Both halves of
that row are true, which is what makes the contradiction readable. The note came from
this pass, where run_plan found no probe registered for AC-01 and moved
on; the number came from the previous pass, still sitting in the same
Criterion object, because acceptance_plan() was called once
outside the loop and both passes wrote into those eight instances. Worse than a wrong
number, it is a stale one: 1420 was measured with the old transcription model, and the
model is precisely what changed. The fix is one line moved. Build the plan inside the
loop, so each pass starts from measured=None and an unmeasured criterion
reports what it truly is:
$ uv run python -m labs.acceptance # fresh plan per pass
[FAIL] AC-01 wake word to first spoken word not measured ms <= 2000.0 no measurement registered
...
Score: 6 of 8 criteria met (75.0%)
Unmet: AC-01 (blocking)
Unmet: AC-03 (exception allowed)
Verdict: NOT ACCEPTED
NOT ACCEPTED is the correct answer to a run that skipped a blocking criterion, and it costs you the four minutes you were trying to save. The general lesson is about where results are allowed to live. An object holding both the promise and the outcome is convenient right up to the second run, and it needs either a fresh instance per run or an explicit reset. Take the fresh instance: a reset is a line somebody forgets to update when a ninth field arrives.
Checkpoint, and the one exception on the sheet
- I can write a criterion for something in my own build as a limit, a unit and a direction, and say which of the two directions makes it better.
- I can explain why AC-03 is the only non-blocking entry, and what a plan of all-blocking criteria does to a team the first time a soft one misses.
- I can say what
plan_idhashes, what it deliberately ignores, and what two runs with different fingerprints do and do not prove. - Shown a report row that reads PASS beside a note saying nothing was measured, I can name the object that held the old number and the call that should have been inside the loop.
- I know why a measurement that raises sets
measuredback toNone, and what the report would claim if it did not. - I can point at the two lines of this chapter's second run that show the cost of the accuracy fix, and say which criterion now needs watching.
Exercise 1 — add the ninth criterion. Pick something in your build that has no number yet: recovery time after the watchdog fires, or wake word false alarms per hour of silence. Add it to the plan with a limit and a direction, run the script without writing its probe, and read the verdict.
The report shows AC-09 as FAIL with "not measured" and "no measurement registered", the score drops to eight of nine, and the verdict goes back to NOT ACCEPTED if you made it blocking. Both of those are correct and both are uncomfortable, which is the exercise. The plan fingerprint changes too, because the promise now has a ninth clause. Leave the row failing until you have written the probe: a criterion nobody measures is the one most likely to be the reason she disappoints someone in month three.
Exercise 2 — read the history back. Write
trend(path) that loads docs/acceptance.json and prints one
line per run with the date, the score and the plan fingerprint, marking any run
whose fingerprint differs from the one before it.
Walk the list in order and print something like
f"{run['recorded_at'][:10]} {met}/{total} {run['plan']}", then
append a marker when run["plan"] != previous. Across the two runs in
this chapter you get 6/8 and 7/8 under one fingerprint, which is a system that
improved. Now edit AC-05 down to 10 facts, re-run, and watch a third line appear
with a new fingerprint and a better score. Those two kinds of progress print
identically until the fingerprint column tells them apart, and that column is the
entire reason the file is worth keeping.
Exercise 3 — make the exit code carry the verdict. Have
main exit 0 for ACCEPTED, 1 for NOT ACCEPTED and 2 when there are
exceptions, then run it from a shell and print $?.
Map the verdict string to a number and call sys.exit on it. The third
code is what makes this more than a pass/fail gate: an automated job can refuse to
deploy on 1, log a warning on 2 and stay quiet on 0, so the policy lives in the
job instead of being argued out in the report. Wire it into the service unit from
chapter 54 as a pre-start check and she declines to come up on a machine that has
not met her own criteria.
There is a signed sheet now, with a score, a fingerprint and one honest hole in it. AC-03 is that hole, and it will not close by editing code: a rating of 3.8 out of 5 means three listeners in twenty found her tone wrong for the moment, which is a question about her manner and not her machinery. Chapter 60 gives that feedback somewhere to go, storing a preference profile that nudges verbosity and tone within clamped bounds and generating the prompt addendum from it, so the next time someone rates a reply the system can do something with the answer.