Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 believed

A 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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    : 6

Six 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
Summary(total, passed, failed, timeout, infra_error, not_run)

Six fields, and the sixth is not like the others.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
balanced         total=400 accounted=400 balanced=True
five jobs lost   total=400 accounted=395 balanced=False

total 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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  : 5

The 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 points

Chapter 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 number

The 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 all

Five 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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

Three blocks in a row joined by two arrows. On the left, the job list, expanded from the test suite and the seed policy, which fixes the total before anything runs. In the middle, the results, one per job at most, each a RunResult from Module 25 — and some jobs produce none. On the right, the summary, whose counts are reconciled against the job list so that the parts add up to the total and anything missing is counted as not run. An arrow labelled submit runs from the job list to the results, and an arrow labelled reconcile runs from the results to the summary.The job listfixes the totalResultsat most one per jobSummaryparts add to totalsubmitreconcile12
Figure 1 — the job list is the authority. It fixes the total before anything runs, each job produces at most one result, and the summary is only trustworthy because its parts are reconciled against the list rather than counted from whatever arrived.

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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: True

The 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.9

Six 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.
  • total plus not_run turns a tally into a checkable invariant: the parts must sum to the total.
  • Without not_run the arithmetic cannot detect a loss — the report is internally consistent and never mentions the missing jobs.
  • total comes from the job list, never from counting the results.
  • Exclude infra_error and not_run from 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.
  • submit is lossy; reconcile is 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_rate is None for an empty regression, because 0.0 renders 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.