Python Part II · Module 26 · Regression Automation
What a Regression Must Guarantee
Module 25 produced a function that runs one test once and returns one precise object. A regression is that function in a loop, which sounds like the easy part and is where the interesting failures live. Four hundred jobs produce four hundred opportunities for a result to go missing, and a summary has no way of noticing — it reports what it was given. The properties that stop that happening are cheap to design in and nearly impossible to retrofit, because by the time anybody notices a number is wrong, there is no record of what it should have been.
Advanced19 min readPythonPart IIAutomationRegressionVerification Flow
Part II · Module 26 · Chapter 26.1 · What a Regression Must Guarantee
1. Three Guarantees
GUARANTEES = [
("every job accounted for",
"the summary's parts add up to the number of jobs submitted"),
("every failure reproducible",
"each failing result carries the one command that re-runs it"),
("a summary wrong in neither direction",
"it must not flatter the design, and must not blame it unfairly"),
]
for name, what in GUARANTEES:
print(f"{name:<38} {what}")
print()
print("none of the three is about speed")
print("all three are about whether the number at the top can be believed")every job accounted for the summary's parts add up to the number of jobs submitted
every failure reproducible each failing result carries the one command that re-runs it
a summary wrong in neither direction it must not flatter the design, and must not blame it unfairly
none of the three is about speed
all three are about whether the number at the top can be believedA regression's output is one number that a lot of people act on. Somebody decides whether to tape out partly on the strength of it, and nobody re-derives it by hand. The guarantees are all about whether that number is trustworthy, and the second guarantee is what makes the first two useful — a failure you cannot reproduce is a number you cannot act on.
Speed matters and it is Module 27's subject. A fast regression that loses jobs is worse than a slow one that does not, because it produces a confident wrong answer sooner.
2. How a Job Goes Missing
WAYS = [
("the machine running the job was rebooted", "no result was ever written"),
("the scheduler killed the job for exceeding a limit", "the runner never returned"),
("the result file was written to a shared path two jobs used", "one overwrote the other"),
("the parent crashed while collecting", "results after that point were lost"),
("the job was never submitted", "a filter dropped it silently"),
("the result file is corrupt", "json.loads raised, the exception was caught and logged"),
]
print(f"{'what happened':<52} {'consequence'}")
for what, consequence in WAYS:
print(f"{what:<52} {consequence}")
print()
print("ways a job goes missing :", len(WAYS))
print("of which the LOOP would notice :", 0)
print("of which a COUNT would notice :", len(WAYS))what happened consequence
the machine running the job was rebooted no result was ever written
the scheduler killed the job for exceeding a limit the runner never returned
the result file was written to a shared path two jobs used one overwrote the other
the parent crashed while collecting results after that point were lost
the job was never submitted a filter dropped it silently
the result file is corrupt json.loads raised, the exception was caught and logged
ways a job goes missing : 6
of which the LOOP would notice : 0
of which a COUNT would notice : 6Six ordinary ways, and a loop over results notices none of them — it iterates what it has. The sixth is the cruellest: somebody wrote a careful try/except around the parse, logged a warning, and continued, which is Chapter 14.9's silent-except in its most defensible-looking costume.
Every one of them is caught by a single comparison: how many results did we get, against how many jobs did we submit.
3. The Summary
Summary(total, passed, failed, timeout, infra_error, not_run)Six fields, and the sixth is not like the others.
from dataclasses import dataclass
@dataclass(frozen=True)
class Summary:
total: int
passed: int
failed: int
timeout: int
infra_error: int
not_run: int
@property
def accounted(self):
return self.passed + self.failed + self.timeout + self.infra_error + self.not_run
@property
def balanced(self):
return self.total == self.accounted
balanced = Summary(total=400, passed=380, failed=12, timeout=3, infra_error=5, not_run=0)
lost = Summary(total=400, passed=380, failed=12, timeout=3, infra_error=0, not_run=0)
for name, s in (("balanced", balanced), ("five jobs lost", lost)):
print(f"{name:<16} total={s.total} accounted={s.accounted} balanced={s.balanced}")balanced total=400 accounted=400 balanced=True
five jobs lost total=400 accounted=395 balanced=Falsetotal and not_run together turn a tally into a checkable invariant:
total == passed + failed + timeout + infra_error + not_run
That is the first guarantee, expressed as arithmetic a program can assert. Everything else in this module exists to keep it true.
Without not_run, there is nothing to check:
from dataclasses import dataclass
@dataclass(frozen=True)
class NoNotRun:
passed: int
failed: int
timeout: int
infra_error: int
naive = NoNotRun(passed=380, failed=12, timeout=3, infra_error=0)
parts = naive.passed + naive.failed + naive.timeout + naive.infra_error
print("the parts sum to :", parts)
print("compared against :", "nothing — there is no total")
print()
print("the report reads :", f"{naive.passed}/{parts}", f"= {100 * naive.passed / parts:.0f}%")
print("jobs submitted :", 400)
print("jobs mentioned :", parts)
print("silently absent :", 400 - parts)the parts sum to : 395
compared against : nothing — there is no total
the report reads : 380/395 = 96%
jobs submitted : 400
jobs mentioned : 395
silently absent : 5The report is internally consistent and wrong. 380 of 395 is a true statement about the results that arrived, and the five that did not arrive are not mentioned anywhere, because nothing in the data structure knows they existed.
total has to come from the job list, not from the results — counting the results and calling that the total is exactly the mistake. Chapter 26.2 builds that list and Chapter 26.6 does the comparison.
4. Wrong in Two Directions
PASSED, TOTAL, INFRA, NOT_RUN = 380, 400, 5, 0
tested = TOTAL - INFRA - NOT_RUN
print("jobs submitted :", TOTAL)
print("infrastructure errors:", INFRA)
print("jobs that tested the design:", tested)
print()
print("naive passed/total :", f"{PASSED}/{TOTAL}", f"= {100 * PASSED / TOTAL:.1f}%")
print("honest passed/tested:", f"{PASSED}/{tested}", f"= {100 * PASSED / tested:.1f}%")
print("difference :", f"{100 * PASSED / tested - 100 * PASSED / TOTAL:.2f} points")jobs submitted : 400
infrastructure errors: 5
jobs that tested the design: 395
naive passed/total : 380/400 = 95.0%
honest passed/tested: 380/395 = 96.2%
difference : 1.20 pointsChapter 23.9 Section 7's denominator rule, at regression scale. Five licence failures move the headline figure by 1.2 points — small enough that nobody queries it, large enough to matter in a release conversation, and in the wrong direction: the design is being blamed for a licence server.
The opposite error is just as available:
SCENARIOS = [
("count a lost job as nothing", "shrinks the denominator", "too optimistic"),
("count an infra error as a failure", "blames the design", "too pessimistic"),
("count a timeout as a pass", "hides a hang", "too optimistic"),
("count a compile error as 400 failures", "one cause, four hundred rows", "too pessimistic"),
("report only what arrived", "silently drops the missing", "too optimistic"),
]
print(f"{'mistake':<40} {'effect':<32} direction")
for mistake, effect, direction in SCENARIOS:
print(f"{mistake:<40} {effect:<32} {direction}")
opt = sum(1 for *_, d in SCENARIOS if d == "too optimistic")
print()
print("too optimistic :", opt)
print("too pessimistic:", len(SCENARIOS) - opt)
print("which is worse :", "the optimistic ones — nobody investigates a good number")mistake effect direction
count a lost job as nothing shrinks the denominator too optimistic
count an infra error as a failure blames the design too pessimistic
count a timeout as a pass hides a hang too optimistic
count a compile error as 400 failures one cause, four hundred rows too pessimistic
report only what arrived silently drops the missing too optimistic
too optimistic : 3
too pessimistic: 2
which is worse : the optimistic ones — nobody investigates a good numberThe asymmetry at the bottom is the one to remember. A pessimistic summary is annoying and self-correcting — somebody investigates, finds the licence problem, and fixes the report. An optimistic summary is never investigated, because a good number is exactly what everybody hoped for.
5. The Summary's Vocabulary Is Not the Result's
RESULT_STATUSES = ("PASS", "FAIL", "COMPILE_ERROR", "NO_FINISH", "ERROR")
SUMMARY_FIELDS = ("passed", "failed", "timeout", "infra_error", "not_run")
print("Chapter 25.1 statuses:", RESULT_STATUSES)
print("summary count fields :", SUMMARY_FIELDS)
print()
print("same length:", len(RESULT_STATUSES) == len(SUMMARY_FIELDS))
print("same meanings:", False)
print()
for note in ("timeout promotes a *reason* to a column",
"infra_error renames ERROR",
"COMPILE_ERROR has no column of its own",
"not_run corresponds to no status at all"):
print(" " + note)Chapter 25.1 statuses: ('PASS', 'FAIL', 'COMPILE_ERROR', 'NO_FINISH', 'ERROR')
summary count fields : ('passed', 'failed', 'timeout', 'infra_error', 'not_run')
same length: True
same meanings: False
timeout promotes a *reason* to a column
infra_error renames ERROR
COMPILE_ERROR has no column of its own
not_run corresponds to no status at allFive and five, and they do not correspond. That is worth sitting with rather than fixing, because each difference is defensible for a summary and none of them should reach back and change the result type.
timeout promotes Chapter 25.6's reason="TIMEOUT" into a column, because at regression scale a hang is the thing people most want counted separately. infra_error is ERROR under a name a non-programmer reads correctly. COMPILE_ERROR has no column because a broken build produces one cause and four hundred identical rows — Chapter 25.3 Section 3's point — so a summary reports it once, elsewhere.
The mapping is therefore a function, and it should be written and tested rather than assumed:
from collections import Counter
STATUS_TO_FIELD = {
"PASS": "passed",
"FAIL": "failed",
"ERROR": "infra_error",
"COMPILE_ERROR": "infra_error",
}
def to_field(status, reason=None):
if status == "NO_FINISH":
return "timeout" if reason == "TIMEOUT" else "failed"
try:
return STATUS_TO_FIELD[status]
except KeyError:
raise ValueError(f"no summary column for status {status!r}") from None
CASES = [("PASS", None), ("FAIL", "UVM_ERROR"), ("NO_FINISH", "TIMEOUT"),
("NO_FINISH", "NO_END_MARKER"), ("ERROR", "no licence"),
("COMPILE_ERROR", None)]
for status, reason in CASES:
print(f"{status:<14} reason={str(reason):<15} -> {to_field(status, reason)}")
print()
print("every status is mapped:", sorted({to_field(s, r) for s, r in CASES}))
try:
to_field("MAYBE")
except ValueError as exc:
print("unknown status :", exc)PASS reason=None -> passed
FAIL reason=UVM_ERROR -> failed
NO_FINISH reason=TIMEOUT -> timeout
NO_FINISH reason=NO_END_MARKER -> failed
ERROR reason=no licence -> infra_error
COMPILE_ERROR reason=None -> infra_error
every status is mapped: ['failed', 'infra_error', 'passed', 'timeout']
unknown status : no summary column for status 'MAYBE'Three decisions in that function deserve defending out loud, because each is a judgement somebody will disagree with.
A NO_FINISH that is not a timeout counts as failed. Chapter 25.6 distinguished an imposed timeout from an inferred hang; the summary keeps only the certain one as its own column and folds the inferred one into failures, because a testbench that stopped logging without finishing is a testbench problem.
COMPILE_ERROR folds into infra_error for counting purposes. Both share the property that matters to a denominator — the design was never exercised — even though they send people to different places. The bucket list in Chapter 26.7 keeps them separate; the headline does not.
An unmapped status raises. A new status added to the result type and forgotten here would otherwise silently vanish from the summary and break the invariant, and the exception names the status. Chapter 25.1 Section 9 validated the vocabulary on the way in; this validates it on the way out.
6. The Shape of a Regression
Read the arrow labels. submit is lossy and reconcile is where the loss is detected — not prevented, detected. Nothing in this module can stop a machine rebooting; what it can do is guarantee that the rebooted job appears in the summary as not_run rather than as nothing at all.
7. Reproducible, or It Did Not Happen
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class JobResult:
test: str
seed: int
sim: str
status: str
reason: Optional[str] = None
run_dir: Optional[str] = None
@property
def reproduce(self):
return (f"flow run --test {self.test} --seed {self.seed} "
f"--sim {self.sim} --waves")
FAILURES = [
JobResult("axi_burst", 1839274, "vcs", "FAIL", "UVM_ERROR", "runs/axi_burst_1839274"),
JobResult("axi_hang", 771002, "vcs", "NO_FINISH", "TIMEOUT", "runs/axi_hang_771002"),
]
for f in FAILURES:
print(f"{f.test:<11} {f.status:<10} {f.reproduce}")
print()
print("every argument comes from the record:", True)
print("nothing needs the regression's own state:", True)
print("usable a month later:", True)axi_burst FAIL flow run --test axi_burst --seed 1839274 --sim vcs --waves
axi_hang NO_FINISH flow run --test axi_hang --seed 771002 --sim vcs --waves
every argument comes from the record: True
nothing needs the regression's own state: True
usable a month later: TrueThe second guarantee is this property and nothing more: a failing row carries enough to re-run itself. Chapter 25.5 Section 5 built the command; here the requirement is that every failing result can produce one without consulting the regression that produced it.
That rules out a few tempting shortcuts. A reproduce command referring to "job 273" is useless once the job list changes. One that needs the regression's working directory is useless once it is cleaned. One that omits the seed is Chapter 25.4 Section 5's bug at scale — and Chapter 26.3 is entirely about not losing it.
8. What the Regression Owns
OWNS = [
"expanding the suite into a concrete job list, before anything runs",
"giving every job its own directory",
"bounding every job and deciding what may be retried",
"collecting a result for every job, or recording that none arrived",
"reconciling the counts against the job list",
"producing one summary and one bucketed failure report",
]
DELEGATES = [
("running one test", "Module 25's run_test"),
("deciding a status", "Chapter 25.6's classifier"),
("parsing a log", "Module 20"),
("the environment", "Module 24"),
("running jobs at the same time", "Module 27"),
("owning the CPU", "the scheduler — Chapter 26.9"),
]
print("the regression owns:")
for item in OWNS:
print(" " + item)
print()
print("and delegates:")
for what, to in DELEGATES:
print(f" {what:<32} {to}")the regression owns:
expanding the suite into a concrete job list, before anything runs
giving every job its own directory
bounding every job and deciding what may be retried
collecting a result for every job, or recording that none arrived
reconciling the counts against the job list
producing one summary and one bucketed failure report
and delegates:
running one test Module 25's run_test
deciding a status Chapter 25.6's classifier
parsing a log Module 20
the environment Module 24
running jobs at the same time Module 27
owning the CPU the scheduler — Chapter 26.9Six owned, six delegated, and the delegation list is why this module is nine chapters rather than thirty. Chapter 25.1 Section 8's prohibitions — no printing, no exiting, no retrying, no raising for a failing test — were written so that this layer could be assembled rather than invented.
The first owned item is the one people skip. Expanding the suite into a concrete job list before anything runs is what makes total knowable, and a regression that discovers its jobs as it goes has no total to reconcile against. Chapter 26.2 is that expansion and Chapter 26.8 depends on it existing.
9. The Contract, Written Down
from dataclasses import dataclass, field
from typing import Optional, Tuple
SUMMARY_FIELDS = ("passed", "failed", "timeout", "infra_error", "not_run")
@dataclass(frozen=True)
class Summary:
total: int
passed: int = 0
failed: int = 0
timeout: int = 0
infra_error: int = 0
not_run: int = 0
def __post_init__(self):
if self.total < 0 or any(getattr(self, f) < 0 for f in SUMMARY_FIELDS):
raise ValueError("counts must not be negative")
if self.accounted != self.total:
raise ValueError(
f"summary does not balance: total={self.total} "
f"but parts sum to {self.accounted}")
@property
def accounted(self):
return sum(getattr(self, f) for f in SUMMARY_FIELDS)
@property
def tested(self):
return self.total - self.infra_error - self.not_run
@property
def pass_rate(self):
return None if self.tested == 0 else self.passed / self.tested
def run_regression(suite, *, sim, seeds, retries=0) -> Summary:
"""Expand, run, reconcile. Never raises for a failing test."""
raise NotImplementedError("Chapters 26.2 to 26.9")
good = Summary(total=400, passed=380, failed=12, timeout=3, infra_error=5)
print("balanced :", good.accounted == good.total)
print("tested :", good.tested)
print("pass rate :", f"{good.pass_rate:.3f}")
print("as a headline:", f"{good.passed}/{good.tested}")
try:
Summary(total=400, passed=380, failed=12, timeout=3)
except ValueError as exc:
print()
print("guard :", exc)
empty = Summary(total=0)
print()
print("empty regression pass rate:", empty.pass_rate, "(not a division by zero)")balanced : True
tested : 395
pass rate : 0.962
as a headline: 380/395
guard : summary does not balance: total=400 but parts sum to 395
empty regression pass rate: None (not a division by zero)Four decisions, and the first is the one that makes this chapter's guarantee enforceable rather than aspirational.
__post_init__ refuses to construct an unbalanced summary. Not a warning, not a log line — the object cannot exist. A regression that lost five jobs now fails loudly at the moment it tries to report, which is the only point at which anybody is still in a position to find out why. Chapter 25.1 Section 9 validated a status the same way, and this is the same instinct applied to an invariant across fields rather than within one.
tested excludes infra_error and not_run, implementing Section 4 rather than leaving it to each report to remember.
pass_rate returns None for an empty regression, not a ZeroDivisionError and not 0.0. A regression of zero jobs has no pass rate, and 0.0 would render as "0%" — the most alarming possible way to display "nothing ran".
run_regression raises NotImplementedError naming the chapters. The invariant is testable now, before any job exists, which is what the next eight chapters are built against.
10. Predict, Run, Observe
from dataclasses import dataclass
FIELDS = ("passed", "failed", "timeout", "infra_error", "not_run")
@dataclass(frozen=True)
class S:
total: int
passed: int = 0
failed: int = 0
timeout: int = 0
infra_error: int = 0
not_run: int = 0
@property
def accounted(self):
return sum(getattr(self, f) for f in FIELDS)
@property
def tested(self):
return self.total - self.infra_error - self.not_run
a = S(10, passed=8, failed=2)
b = S(10, passed=8, failed=1, not_run=1)
c = S(10, passed=8, failed=1, infra_error=1)
# A
print("A:", a.accounted, a.accounted == a.total)
# B
print("B:", S(10, passed=8, failed=1).accounted)
# C
print("C:", b.tested, c.tested)
# D
print("D:", f"{a.passed}/{a.total}", f"{a.passed}/{a.tested}")
# E
print("E:", f"{c.passed / c.total:.2f}", f"{c.passed / c.tested:.2f}")
# F
print("F:", a == S(10, passed=8, failed=2))
# G
print("G:", S(0).accounted, S(0).tested)
# H
print("H:", sorted({a.accounted, b.accounted, c.accounted}))The verified output:
A: 10 True
B: 9
C: 9 9
D: 8/10 8/10
E: 0.80 0.89
F: True
G: 0 0
H: [10]A — the parts add up, so the summary balances.
B — one job unaccounted for, and nothing in this object alone would have noticed without total.
C — both not_run and infra_error reduce the tested denominator.
D — with neither, the two denominators agree, which is why the problem is invisible on a healthy run.
E — one infra error moves the rate from 0.80 to 0.89.
F — frozen dataclasses compare by value, so two summaries of the same regression are equal.
G — an empty regression has zero of everything, and a rate must not be computed from it.
H — all three of a, b and c account for 10, so the set has one element. The unbalanced value in B came from a different summary, built without the missing job assigned anywhere.
11. Exercises
Exercise 1 — three guarantees. Write them out for a regression you have used, and say which it currently provides.
Exercise 2 — not speed. Explain why a fast regression that loses jobs is worse than a slow one that does not.
Exercise 3 — six ways. List the ways a job can go missing in your own flow. Aim for at least five.
Exercise 4 — the defensible silence. Find a try/except in your flow that could swallow a lost result, and say what it should do instead.
Exercise 5 — the invariant. State it as arithmetic and say which field makes it checkable.
Exercise 6 — no total. Build a summary without total and show the report being internally consistent and wrong.
Exercise 7 — where total comes from. Say why counting the results is the wrong way to obtain it.
Exercise 8 — the denominator. Compute both pass rates for a regression with five infra errors. Report the difference in points.
Exercise 9 — two directions. Classify five summary mistakes as optimistic or pessimistic.
Exercise 10 — the asymmetry. Argue in three sentences why optimistic errors are worse.
Exercise 11 — two vocabularies. List your result statuses and your summary columns side by side and find the differences.
Exercise 12 — the mapping. Write to_field for your own statuses and test every one.
Exercise 13 — defend three. Justify folding COMPILE_ERROR, splitting timeout out, and renaming ERROR.
Exercise 14 — the unmapped status. Add a new status and show your mapping raising rather than dropping it.
Exercise 15 — lossy and detected. Say what submit can lose and what reconcile can do about it.
Exercise 16 — reproduce. Add a reproduce property to your result and confirm every argument comes from the record.
Exercise 17 — three shortcuts. Say why a job index, a working directory and a missing seed each break reproducibility.
Exercise 18 — flaky by omission. Describe how a missing reproduce command turns a real failure into a retried one.
Exercise 19 — owned or delegated. Classify ten concerns in your regression. Defend the two hardest.
Exercise 20 — expand first. Say what a regression that discovers jobs as it goes cannot guarantee.
Exercise 21 — refuse to construct. Implement the balance check in __post_init__ and show it rejecting a lost job.
Exercise 22 — the empty regression. Say why pass_rate must be None rather than 0.0.
12. Summary
- A regression guarantees three things: every job accounted for, every failure reproducible, and a summary wrong in neither direction.
- None of them is about speed. A fast regression that loses jobs produces a confident wrong answer sooner.
- Six ordinary events make a job disappear, and a loop over results notices none of them.
- The cruellest is a caught parse exception — Chapter 14.9's silent-except wearing a careful-looking
try. totalplusnot_runturns a tally into a checkable invariant: the parts must sum to the total.- Without
not_runthe arithmetic cannot detect a loss — the report is internally consistent and never mentions the missing jobs. totalcomes from the job list, never from counting the results.- Exclude
infra_errorandnot_runfrom the denominator; five licence failures moved the headline by 1.2 points. - Optimistic mistakes are worse than pessimistic ones, because nobody investigates a good number.
- A summary's vocabulary is a presentation decision; a result's is a data decision. They need not match, and the mapping between them should be a tested function.
- An unmapped status must raise, or a new status silently vanishes and the invariant breaks.
submitis lossy;reconcileis where the loss is detected — not prevented.- Every failing result must carry a command that re-runs it, built only from the record.
- A failure nobody can reproduce becomes one nobody believes, retried into Chapter 25.7's noise floor.
- Expand the suite into a concrete job list before anything runs, or there is no total to reconcile against.
- Refuse to construct an unbalanced summary — a warning gets ignored and an exception does not.
pass_rateisNonefor an empty regression, because0.0renders as the most alarming possible way to say "nothing ran".
The next chapter builds the job list this whole contract rests on.
Where this fits
Part of the Python curriculum.
