Skip to content
VLSI Mentor

Python Part II · Module 26 · Regression Automation

Collecting Results From Many Jobs

Four hundred jobs have run and each was supposed to leave a structured result in its own directory. Collecting them looks like a glob and a loop, and the version everybody writes first produces a summary that is arithmetically consistent, internally plausible, and capable of counting a result from last night's regression as one of tonight's passes. The fix is one parameter, and what makes it work is that it compares against something the results themselves cannot influence.

Advanced19 min readPythonPart IIAutomationRegressionVerification Flow

Part II · Module 26 · Chapter 26.6 · Collecting Results From Many Jobs

1. Each Job Left a Record

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import json
from pathlib import Path

JOBS = [("axi_smoke", 1), ("axi_reset", 2), ("axi_burst", 3),
        ("axi_hang", 4), ("axi_lic", 5), ("axi_gone", 6)]

RECORDS = [
    {"test": "axi_smoke", "seed": 1, "status": "PASS"},
    {"test": "axi_reset", "seed": 2, "status": "FAIL", "reason": "UVM_ERROR"},
    {"test": "axi_burst", "seed": 3, "status": "FAIL", "reason": "UVM_ERROR"},
    {"test": "axi_hang", "seed": 4, "status": "NO_FINISH", "reason": "TIMEOUT"},
    {"test": "axi_lic", "seed": 5, "status": "ERROR", "reason": "no licence"},
    None,
]

for (test, seed), record in zip(JOBS, RECORDS):
    d = Path("runs") / f"{test}_{seed}"
    d.mkdir(parents=True, exist_ok=True)
    if record is not None:
        (d / "record.json").write_text(json.dumps(record), encoding="utf-8")

print("job directories created:", len(JOBS))
print("records written        :", sum(1 for r in RECORDS if r is not None))
print("axi_gone produced      :", "nothing — the machine went away mid-run")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
job directories created: 6
records written        : 5
axi_gone produced      : nothing — the machine went away mid-run

Chapter 26.4 gave each job a directory and Chapter 25.6 gave each a record. Collecting them is now a matter of reading them back — and the directory is the only place the result exists, because Chapter 25.1 Section 8 prohibited a job from writing into a shared summary.

Two more things happened to this regression that are entirely ordinary and are not visible above.

2. Six Files, Six Jobs, Wrong Answer

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import json
from pathlib import Path

JOBS = [("axi_smoke", 1), ("axi_reset", 2), ("axi_burst", 3),
        ("axi_hang", 4), ("axi_lic", 5), ("axi_gone", 6)]

RECORDS = [
    {"test": "axi_smoke", "seed": 1, "status": "PASS"},
    {"test": "axi_reset", "seed": 2, "status": "FAIL", "reason": "UVM_ERROR"},
    {"test": "axi_burst", "seed": 3, "status": "FAIL", "reason": "UVM_ERROR"},
    {"test": "axi_hang", "seed": 4, "status": "NO_FINISH", "reason": "TIMEOUT"},
    {"test": "axi_lic", "seed": 5, "status": "ERROR", "reason": "no licence"},
    None,
]
for (test, seed), record in zip(JOBS, RECORDS):
    d = Path("runs") / f"{test}_{seed}"
    d.mkdir(parents=True, exist_ok=True)
    if record is not None:
        (d / "record.json").write_text(json.dumps(record), encoding="utf-8")

# the job was killed while writing its record
(Path("runs/axi_burst_3") / "record.json").write_text(
    '{"test":"axi_burst","seed":3,"stat', encoding="utf-8")

# a directory left behind by last night's regression
old = Path("runs/axi_old_99")
old.mkdir(parents=True, exist_ok=True)
(old / "record.json").write_text(
    json.dumps({"test": "axi_old", "seed": 99, "status": "PASS"}), encoding="utf-8")

found = sorted(Path("runs").rglob("record.json"))
loaded, corrupt = [], []
for f in found:
    try:
        loaded.append(json.loads(f.read_text(encoding="utf-8")))
    except json.JSONDecodeError:
        corrupt.append(f.parent.name)

print("files found :", len(found))
print("jobs expected:", len(JOBS))
print("the counts match:", len(found) == len(JOBS))
print()
print("parsed      :", len(loaded))
print("corrupt     :", corrupt)
print("would report:", f"{sum(1 for r in loaded if r['status'] == 'PASS')} passed of {len(loaded)}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
files found : 6
jobs expected: 6
the counts match: True

parsed      : 5
corrupt     : ['axi_burst_3']
would report: 2 passed of 5

Six files, six jobs, and the counts match — which is exactly what makes this invisible.

Three separate things are wrong with "2 passed of 5".

axi_burst was killed while writing its record, leaving truncated JSON. The except json.JSONDecodeError handled it gracefully and the job vanished from the report, which is Chapter 26.1 Section 2's sixth way for a job to go missing.

axi_gone produced no record at all and is equally absent.

One of the two passes is axi_old, from last night. Its directory was still on disk, the glob found it, the JSON was valid, and it was counted. Chapter 26.1 Section 4 called this the too-optimistic direction, and here it arrives through the filesystem rather than through arithmetic.

The coincidence that one file was lost and one was gained is what made the totals agree. Count-matching proves nothing, because a glob can only ever produce the intersection of what you wanted and what is there, and the two differences are where the problems live.

3. Reconcile Against the Job List

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
summary = aggregate(results, expected=len(jobs))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
JOBS = [("axi_smoke", 1), ("axi_reset", 2), ("axi_burst", 3),
        ("axi_hang", 4), ("axi_lic", 5), ("axi_gone", 6)]

PARSED = [{"test": "axi_smoke", "seed": 1, "status": "PASS"},
          {"test": "axi_reset", "seed": 2, "status": "FAIL"},
          {"test": "axi_hang", "seed": 4, "status": "NO_FINISH"},
          {"test": "axi_lic", "seed": 5, "status": "ERROR"},
          {"test": "axi_old", "seed": 99, "status": "PASS"}]

by_job = {(r["test"], r["seed"]): r for r in PARSED}
wanted = set(JOBS)

matched = [j for j in JOBS if j in by_job]
missing = [j for j in JOBS if j not in by_job]
extra = sorted(k for k in by_job if k not in wanted)

print("expected:", len(JOBS))
print("matched :", len(matched))
print("missing :", missing)
print("extra   :", extra)
print()
print("a glob gives you:", "matched, and nothing else")
print("the problems are in:", "missing and extra")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
expected: 6
matched : 4
missing : [('axi_burst', 3), ('axi_gone', 6)]
extra   : [('axi_old', 99)]

a glob gives you: matched, and nothing else
the problems are in: missing and extra

Three sets, not one count. expected= is what makes the other two computable, and it has one property that nothing on disk has: the job list cannot be influenced by the jobs. A record cannot add itself to it, a stale directory cannot appear in it, and a lost job cannot remove itself from it.

That is why the parameter is expected=len(jobs) and not something derived from the results. Chapter 26.1 Section 3 said total comes from the job list; this is the line where that matters.

4. The Summary Balances

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from collections import Counter

JOBS = [("axi_smoke", 1), ("axi_reset", 2), ("axi_burst", 3),
        ("axi_hang", 4), ("axi_lic", 5), ("axi_gone", 6)]
MATCHED = {("axi_smoke", 1): "PASS", ("axi_reset", 2): "FAIL",
           ("axi_hang", 4): "NO_FINISH", ("axi_lic", 5): "ERROR"}
MISSING = [("axi_burst", 3), ("axi_gone", 6)]

counts = Counter(MATCHED.values())
not_run = len(MISSING)

print("status counts:", dict(sorted(counts.items())))
print("not_run      :", not_run)
print()
accounted = sum(counts.values()) + not_run
print("accounted    :", accounted)
print("total        :", len(JOBS))
print("balanced     :", accounted == len(JOBS))
print()
tested = len(JOBS) - counts["ERROR"] - not_run
print("tested       :", tested)
print("pass rate    :", f"{counts['PASS']}/{tested}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
status counts: {'ERROR': 1, 'FAIL': 1, 'NO_FINISH': 1, 'PASS': 1}
not_run      : 2

accounted    : 6
total        : 6
balanced     : True

tested       : 3
pass rate    : 1/3

Chapter 26.1's invariant, holding. Four results and two not_run account for all six jobs, and the honest pass rate is 1 of 3 rather than the naive glob's 2 of 5.

The two figures differ in both directions at once, which is worth noting: the glob was optimistic about the numerator (it counted a stale pass) and optimistic about the denominator (it excluded the lost jobs entirely by never knowing about them). Neither error is visible without expected=.

5. Two Kinds of Missing

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from pathlib import Path
import json

for name, content in (("axi_burst_3", '{"test":"axi_burst","seed":3,"stat'),
                      ("axi_gone_6", None)):
    d = Path("runs2") / name
    d.mkdir(parents=True, exist_ok=True)
    if content is not None:
        (d / "record.json").write_text(content, encoding="utf-8")


def why_missing(run_dir):
    record = Path(run_dir) / "record.json"
    if not record.exists():
        log = Path(run_dir) / "sim.log"
        return ("no_record", "the job never wrote one"
                if not log.exists() else "a log exists but no record")
    try:
        json.loads(record.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        return ("corrupt_record", f"{exc.msg} char {exc.pos}")
    return ("present", "")


for name in ("axi_burst_3", "axi_gone_6"):
    kind, detail = why_missing(Path("runs2") / name)
    print(f"{name:<14} {kind:<16} {detail}")

print()
print("both count as not_run :", True)
print("both leave evidence   :", False)
print("which one has evidence:", "axi_burst_3 — a truncated record and possibly a log")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
axi_burst_3    corrupt_record   Unterminated string starting at char 29
axi_gone_6     no_record        the job never wrote one

both count as not_run : True
both leave evidence   : False
which one has evidence: axi_burst_3 — a truncated record and possibly a log

Both are not_run for the invariant and they are not the same problem.

A corrupt record means the job ran, did work, and was interrupted while writing — so the directory contains a log, possibly a full one, and the failure is recoverable by hand. The exception even says where the JSON stopped.

No record at all means the job may never have started. Chapter 26.4's allocation created the directory before launch, so an empty directory and a directory with a log are themselves different stories, which is why why_missing looks for the log too.

Folding them into one count loses the only thing a debugger can act on. The invariant needs the number; the report needs the reason — the same split Chapter 25.6 Section 6 made between status and reason, one level up.

6. Extra Results Are Not Harmless

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
JOBS = [("axi_smoke", 1), ("axi_reset", 2)]
ON_DISK = [("axi_smoke", 1, "PASS"), ("axi_reset", 2, "FAIL"),
           ("axi_old", 99, "PASS"), ("axi_smoke", 7, "PASS")]

wanted = set(JOBS)
extra = [(t, s, st) for t, s, st in ON_DISK if (t, s) not in wanted]

print("jobs in this regression:", len(JOBS))
print("records on disk        :", len(ON_DISK))
print("extra                  :", [(t, s) for t, s, _ in extra])
print()
naive_pass = sum(1 for *_, st in ON_DISK if st == "PASS")
real_pass = sum(1 for t, s, st in ON_DISK if (t, s) in wanted and st == "PASS")
print("passes counted naively :", naive_pass)
print("passes actually ours   :", real_pass)
print("inflation              :", naive_pass - real_pass)
print()
print("where the extras came from:")
print("   axi_old/99   a test removed from the suite last week")
print("   axi_smoke/7  a different seed from a previous sweep")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
jobs in this regression: 2
records on disk        : 4
extra                  : [('axi_old', 99), ('axi_smoke', 7)]

passes counted naively : 3
passes actually ours   : 1
inflation              : 2

where the extras came from:
   axi_old/99   a test removed from the suite last week
   axi_smoke/7  a different seed from a previous sweep

Three passes counted, one of them real. Both extras are mundane: a test deleted from the suite whose directory nobody cleaned, and a seed from a previous sweep in a shared root.

Chapter 26.4 Section 7 prevents this by refusing to run into a non-empty directory and by giving each regression its own root. This is the detection half, and both are worth having, because prevention covers the directories a regression creates and detection covers the ones it merely finds.

The right response to an extra is to report it, not delete it. An unexpected record is evidence that something is misconfigured — a shared root, an uncleaned previous run, a job list that changed mid-flight — and deleting it removes the only trace.

7. The Shape of Aggregation

Three blocks in a row joined by two arrows. On the left, the job list, which fixes what was asked for and which nothing on disk can alter. In the middle, reconciliation, which compares the job list against the records found on disk and produces three sets: matched, missing and extra. On the right, the summary, whose status counts come from the matched set, whose not-run count comes from the missing set, and which reports the extra set separately so that a stale record is visible rather than counted. An arrow labelled asked for runs from the job list to reconciliation, and an arrow labelled balanced runs from reconciliation to the summary.The job listwhat was asked forReconcilematched, missing, extraSummarybalanced, with reasonsasked forbalanced12
Figure 1 — aggregation is a three-way comparison, not a read. The job list is the authority because nothing on disk can alter it; the records are whatever happens to be there; and the summary is only trustworthy because the matched, missing and extra sets were all computed and all reported.

8. Aggregating While It Runs

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from collections import Counter

JOBS = [("t1", 1), ("t2", 2), ("t3", 3), ("t4", 4), ("t5", 5), ("t6", 6)]
ARRIVED = {("t1", 1): "PASS", ("t2", 2): "FAIL", ("t3", 3): "PASS"}


def partial(jobs, records):
    counts = Counter(records.get(j) for j in jobs if j in records)
    done = sum(counts.values())
    return {"total": len(jobs), "done": done, "outstanding": len(jobs) - done,
            "counts": dict(sorted(counts.items())),
            "pass_rate_so_far": None if done == 0 else f"{counts['PASS']}/{done}"}


p = partial(JOBS, ARRIVED)
for k, v in p.items():
    print(f"{k:<17} {v}")

print()
print("is 'outstanding' the same as 'not_run'?", False)
print("outstanding means :", "no result YET")
print("not_run means     :", "no result, and we have stopped waiting")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
total             6
done              3
outstanding       3
counts            {'FAIL': 1, 'PASS': 2}
pass_rate_so_far  2/3

is 'outstanding' the same as 'not_run'? False
outstanding means : no result YET
not_run means     : no result, and we have stopped waiting

A regression that takes eight hours should not keep its results to itself for eight hours. Reading the records as they appear costs nothing — they are already on disk, one per directory — and it turns a long silence into a progress figure.

The distinction in the last three lines is the one that must not be collapsed. outstanding and not_run are the same observation at different times: during the run a missing record means the job has not finished, and after the run it means the job produced nothing. Reporting a partial summary as though it were final would count every still-running job as a loss.

So a partial summary deliberately does not balance:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from collections import Counter

JOBS = [("t1", 1), ("t2", 2), ("t3", 3), ("t4", 4)]
ARRIVED = {("t1", 1): "PASS", ("t2", 2): "FAIL"}

counts = Counter(ARRIVED.values())
done = sum(counts.values())

print("during the run:")
print("   accounted for:", done, "of", len(JOBS))
print("   balances     :", done == len(JOBS))
print("   is that a bug:", False, "- the rest have not finished")
print()
print("after the run, the same shortfall means something else:")
print("   accounted for:", done, "of", len(JOBS))
print("   not_run      :", len(JOBS) - done)
print("   balances     :", done + (len(JOBS) - done) == len(JOBS))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
during the run:
   accounted for: 2 of 4
   balances     : False
   is that a bug: False - the rest have not finished

after the run, the same shortfall means something else:
   accounted for: 2 of 4
   not_run      : 2
   balances     : True

The same numbers, two readings, separated only by whether the regression has stopped.

That is why Chapter 26.1's Summary refuses to construct unbalanced and a partial report is a different object. Trying to reuse one type for both would force the constructor's guard to be optional, and a guard that can be switched off is the guard that is off on the night it mattered.

Two practical consequences.

A progress report needs a denominator too. "127 passed" says nothing; "127 of 400, 31 outstanding" says the regression is three-quarters through and roughly on track. The job list supplies it exactly as it does for the final summary.

Watch the rate, not just the count. A regression whose done figure has not moved in an hour has a problem that no individual job's status will reveal — the licence server, the farm, the filesystem — and it is visible from the aggregator long before any timeout fires.

9. The Aggregator

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import json
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path

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
    missing: tuple = ()
    extra: tuple = ()
    unreadable: tuple = ()

    def __post_init__(self):
        if self.accounted != self.total:
            raise ValueError(f"does not balance: {self.accounted} != {self.total}")

    @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


def to_field(status, reason=None):
    if status == "NO_FINISH":
        return "timeout" if reason == "TIMEOUT" else "failed"
    mapping = {"PASS": "passed", "FAIL": "failed",
               "ERROR": "infra_error", "COMPILE_ERROR": "infra_error"}
    if status not in mapping:
        raise ValueError(f"no summary column for status {status!r}")
    return mapping[status]


def read_records(root):
    """Return {(test, seed): record} and the directories whose record would not parse."""
    found, unreadable = {}, []
    for path in sorted(Path(root).rglob("record.json")):
        try:
            r = json.loads(path.read_text(encoding="utf-8"))
            found[(r["test"], r["seed"])] = r
        except (json.JSONDecodeError, KeyError):
            unreadable.append(path.parent.name)
    return found, unreadable


def aggregate(records, jobs, unreadable=()):
    wanted = list(dict.fromkeys(jobs))
    counts = Counter()
    missing = []
    for job in wanted:
        r = records.get(job)
        if r is None:
            missing.append(job)
            continue
        counts[to_field(r["status"], r.get("reason"))] += 1
    extra = tuple(sorted(k for k in records if k not in set(wanted)))
    return Summary(total=len(wanted), not_run=len(missing),
                   missing=tuple(missing), extra=extra,
                   unreadable=tuple(unreadable),
                   **{f: counts[f] for f in SUMMARY_FIELDS if f != "not_run"})


JOBS = [("axi_smoke", 1), ("axi_reset", 2), ("axi_burst", 3),
        ("axi_hang", 4), ("axi_lic", 5), ("axi_gone", 6)]
RECORDS = {("axi_smoke", 1): {"test": "axi_smoke", "seed": 1, "status": "PASS"},
           ("axi_reset", 2): {"test": "axi_reset", "seed": 2, "status": "FAIL",
                              "reason": "UVM_ERROR"},
           ("axi_hang", 4): {"test": "axi_hang", "seed": 4, "status": "NO_FINISH",
                             "reason": "TIMEOUT"},
           ("axi_lic", 5): {"test": "axi_lic", "seed": 5, "status": "ERROR",
                            "reason": "no licence"},
           ("axi_old", 99): {"test": "axi_old", "seed": 99, "status": "PASS"}}

s = aggregate(RECORDS, JOBS, unreadable=("axi_burst_3",))

print("total      :", s.total)
print("passed     :", s.passed)
print("failed     :", s.failed)
print("timeout    :", s.timeout)
print("infra_error:", s.infra_error)
print("not_run    :", s.not_run)
print("balanced   :", s.accounted == s.total)
print("pass rate  :", f"{s.passed}/{s.tested}")
print()
print("missing    :", list(s.missing))
print("extra      :", list(s.extra))
print("unreadable :", list(s.unreadable))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
total      : 6
passed     : 1
failed     : 1
timeout    : 1
infra_error: 1
not_run    : 2
balanced   : True
pass rate  : 1/3

missing    : [('axi_burst', 3), ('axi_gone', 6)]
extra      : [('axi_old', 99)]
unreadable : ['axi_burst_3']

Five decisions, and the first is what makes the whole thing safe.

aggregate takes the job list, not a count. The annotation's expected=len(jobs) catches a total mismatch; taking the list itself also produces missing and extra by name, which is the difference between "one job is missing" and "axi_burst seed 3 is missing".

missing, extra and unreadable are carried in the summary. Chapter 24.4 Section 6's rule: a step that made a judgement reports what it decided. A summary that knows two jobs are missing and does not say which is barely better than one that does not know.

unreadable is separate from missing even though the job appears in both. axi_burst_3 is not_run for the invariant and has a corrupt record somebody can look at — Section 5's distinction, preserved all the way into the summary.

dict.fromkeys(jobs) deduplicates the job list before counting, so Chapter 26.2 Section 6's duplicate cannot inflate total and break the balance.

Summary.__post_init__ still refuses to construct. Everything above is machinery for getting the counts right; the constructor is what guarantees nobody ships a summary that does not add up.

10. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import json
from collections import Counter

JOBS = [("a", 1), ("b", 2), ("c", 3)]
RECS = {("a", 1): "PASS", ("b", 2): "FAIL", ("z", 9): "PASS"}

# A
print("A:", len(JOBS), len(RECS))

# B
print("B:", [j for j in JOBS if j in RECS])

# C
print("C:", [j for j in JOBS if j not in RECS])

# D
print("D:", sorted(k for k in RECS if k not in set(JOBS)))

# E
print("E:", sum(1 for v in RECS.values() if v == "PASS"))

# F
print("F:", sum(1 for j in JOBS if RECS.get(j) == "PASS"))

# G
counts = Counter(RECS[j] for j in JOBS if j in RECS)
print("G:", dict(sorted(counts.items())), sum(counts.values()) + 1 == len(JOBS))

# H
try:
    json.loads('{"a": 1')
except json.JSONDecodeError as exc:
    print("H:", type(exc).__name__, exc.pos)

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: 3 3
B: [('a', 1), ('b', 2)]
C: [('c', 3)]
D: [('z', 9)]
E: 2
F: 1
G: {'FAIL': 1, 'PASS': 1} True
H: JSONDecodeError 7

A — three jobs, three records, and the counts match. Section 2's trap in miniature.

B — only two actually correspond.

C — one job has no record: not_run.

D — one record belongs to no job: extra.

E — counting every record gives two passes.

F — counting only ours gives one. The inflation, in two lines.

G — the matched counts plus one not_run account for all three jobs.

H — a truncated record raises with the character position, which is worth keeping in the report.

11. Exercises

Exercise 1 — where results live. Say why each job writes its own record rather than appending to a shared file, citing Chapter 25.1.

Exercise 2 — matching counts. Construct a case where the file count equals the job count and the summary is wrong.

Exercise 3 — three problems. For that case, name all three and say which the glob could detect.

Exercise 4 — the intersection. Explain in one sentence why a glob can only produce matched results.

Exercise 5 — three sets. Compute matched, missing and extra for a regression of your own.

Exercise 6 — the authority. Say what property the job list has that nothing on disk has.

Exercise 7 — balance it. Show the invariant holding with your not_run count included.

Exercise 8 — both directions. Say how the naive glob was optimistic about both the numerator and the denominator.

Exercise 9 — two kinds of missing. Produce a corrupt record and an absent one, and give each its own classification.

Exercise 10 — the evidence. Say what a corrupt record leaves that an absent one does not.

Exercise 11 — look for the log. Extend why_missing to distinguish "never started" from "ran and wrote no record".

Exercise 12 — the silent except. Find the except json.JSONDecodeError in your own flow and say what makes it survivable or not.

Exercise 13 — extras. Put a stale record in your results root and show it being counted.

Exercise 14 — provenance. Name two mundane ways an extra record appears.

Exercise 15 — report, do not delete. Argue for reporting an extra rather than removing it.

Exercise 16 — prevention and detection. Say which part Chapter 26.4 handles and which part this chapter handles.

Exercise 17 — names, not counts. Say what expected=len(jobs) cannot tell you that the job list can.

Exercise 18 — carry the sets. Add missing, extra and unreadable to your summary and print them.

Exercise 19 — deduplicate first. Show a duplicated job inflating total and breaking the balance, then fix it.

Exercise 20 — a partial summary. Produce one mid-run with done, outstanding and a rate so far.

Exercise 21 — the same shortfall. Explain how the identical numbers mean different things before and after the regression stops.

Exercise 22 — two types. Argue against reusing Summary for a progress report.

Exercise 23 — the rate. Say what a done figure that has not moved in an hour tells you that no job status can.

Exercise 24 — refuse to construct. Confirm your summary raises rather than reporting an unbalanced total.

12. Summary

  • Each job writes its own record in its own directory, because Chapter 25.1 Section 8 forbade writing into a shared summary.
  • A matching file count proves nothing. Six files for six jobs hid one corrupt record, one absent job, and one stale record from a previous run.
  • A glob can only ever produce the intersection of what you wanted and what is there.
  • Reconcile against the job list — it is the only authority the results cannot influence.
  • expected= is what makes missing and extra computable, and passing the list rather than the count makes them nameable.
  • The invariant then balances: matched results plus not_run equal the total.
  • The naive answer was optimistic twice over — a stale pass in the numerator and lost jobs excluded from the denominator.
  • A corrupt record and an absent one are both not_run and are not the same problem; one leaves evidence and the exception even says where the JSON stopped.
  • except json.JSONDecodeError: continue is Chapter 14.9's silent-except, survivable only because expected= reinstates the job.
  • Extra records are counted as passes unless something excludes them, which is Chapter 26.1's too-optimistic direction arriving through the filesystem.
  • Report an extra rather than deleting it — an unexpected record is evidence that something is misconfigured.
  • Chapter 26.4 prevents collisions and this chapter detects them; prevention covers directories you create, detection covers ones you find.
  • Carry missing, extra and unreadable in the summary, by name.
  • Deduplicate the job list before counting, or Chapter 26.2's duplicate inflates the total.
  • Aggregate while it runs — the records are already on disk, and a long regression should report progress rather than silence.
  • outstanding and not_run are the same observation at different times, and collapsing them counts running jobs as losses.
  • A partial summary is a different type, because Chapter 26.1's balance guard must never become optional.
  • A progress report needs the denominator too, and the job list supplies it.
  • Watch the rate: a done count that stops moving reveals a systemic problem long before any timeout fires.
  • Let the summary's constructor refuse to balance, so no unbalanced number can be shipped.

The next chapter takes the failures this summary counted and groups them by cause, so a morning review reads a handful of buckets instead of a list.

Where this fits

Part of the Python curriculum.