Skip to content
VLSI Mentor

Python Part II · Module 20 · Simulation and UVM Log Parsing

Turning One Log Into a Result Record

Fifteen chapters have each added one fact the parser can extract. This one puts them together and defines the thing the rest of the curriculum consumes: a single dictionary per log, with every field present on every run, collected in one pass over the file, holding a few lines of state whatever the log's size. The interesting decisions at this point are not about parsing any more — they are about what belongs in the record, what belongs to whoever calls it, and the one field the canonical purpose asks for that the log genuinely cannot supply.

Intermediate18 min readPythonPart IILog ParsingUVMVerification Flow

Part II · Module 20 · Chapter 20.16 · Turning One Log Into a Result Record

1. The Product

Chapter 20.2 built a checker that printed FAIL. Everything since has been about what else is in the log and how to get it out without reading the file twice or holding it in memory. What comes out the far end is one value:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
{"test": name, "seed": seed, "status": status,
 "errors": n_err, "first_failure": first}

That is the canonical annotation, and it is the shape rather than the full list — the record this chapter ships has seventeen keys, and Section 3 names the chapter that earned each one.

The reason to care about the shape is what happens next. Chapter 21.1 designs a result record for CSV and JSON; Chapter 25.6 returns one from a simulation runner; Chapter 26.6 aggregates them across a regression; Chapter 30.2 decides what a report shows. All four are downstream of this dictionary, so a field missing here is a field missing from the whole flow, and a field whose meaning is vague here is vague everywhere.

2. Four Groups of Field

Four groups of field in two rows. Top row: identity, which is the log path, the test name and the seed, and is what makes a failure reproducible; and counts, which are the errors, fatals, warnings and assertion failures, and are measurements. Bottom row: evidence, which is the first failing line, its time and the lines around it, and is what a debugger opens first; and verdict, which is the status, whether the run started and finished, and whether the parser's counts agreed with the tool's, and is the only group that is policy rather than measurement.Identitylog, test, seed — reproduceitCountserrors, fatals, warnings,SVAEvidencefirst failure, time,contextVerdictstatus — the only policyherewhich runwhat to do12
Figure 2 — the record's four groups. Identity says which run this was, counts say how much went wrong, evidence says what to look at, and the verdict says what to do — and only the verdict is policy.

The split matters because the groups have different owners. Counts and evidence are measurements — they change when the tool's output format changes. The verdict is policy — it changes when the project decides what counts as a failure. Chapter 20.1 Section 4 made that argument on a five-line script; here it is a property of the record, and it is why status sits beside the counts rather than replacing them.

3. Every Field, and the Chapter That Earned It

FieldTypeFrom
logstr20.4 — report the input you actually read
teststr or None20.8 — the plusarg or the RNTST banner
seedint or None20.8 — a list of simulator spellings
errorsint20.7 — messages, not summary tallies
fatalsint20.7
warningsint20.7
assertionsint20.9 — SVA failures carry no UVM severity
first_failurestr or None20.7 — the first, by the is None guard
first_failure_timeint or None20.9 — earliest by time, not file position
contextlist of str20.10 — before, the failure, after
context_markerint or None20.10 — which element is the failure
startedbool20.11 — the RNTST banner
finishedbool20.11/20.12 — an end-of-sim marker
unparsedint20.6 — lines nothing recognised
counts_agreebool or None20.7 — our counts versus the tool's
statusstr20.11 — PASS, FAIL, NO_FINISH, NO_RUN
runtime_sfloat or Nonenot from the log — Section 6

Seventeen fields and sixteen chapters. A field with no chapter behind it is not in the record — that is the rule that keeps a result record from accumulating things somebody once needed, and it is worth applying literally when you extend this for your own flow.

4. The Parser

Azvya Education Pvt. Ltd.VLSI Mentor
smoke.log
xcelium: *N,RUNCMD: run -f cmd.f +UVM_TESTNAME=axi_smoke_test
Simulation random seed = 1839274
UVM_INFO @ 0: reporter [RNTST] Running test axi_smoke_test...
UVM_INFO drv.sv(60) @ 141700: uvm_test_top.env.agt.drv [WR] issuing write
UVM_ERROR drv.sv(88) @ 141900: uvm_test_top.env.agt.drv [WR] bad handshake
UVM_INFO drv.sv(92) @ 142000: uvm_test_top.env.agt.drv [WR] retrying
UVM_ERROR drv.sv(104) @ 180400: uvm_test_top.env.agt.drv [WR] timeout
--- UVM Report Summary ---
UVM_INFO :    3
UVM_WARNING :    0
UVM_ERROR :    2
UVM_FATAL :    0
TEST DONE
Azvya Education Pvt. Ltd.VLSI Mentor
reset.log
xcelium: *N,RUNCMD: run -f cmd.f +UVM_TESTNAME=axi_reset_test
Simulation random seed = 771002
UVM_INFO @ 0: reporter [RNTST] Running test axi_reset_test...
UVM_INFO scb.sv(120) @ 160000: uvm_test_top.env.scb [CHK] 96 compared
--- UVM Report Summary ---
UVM_INFO :    2
UVM_WARNING :    0
UVM_ERROR :    0
UVM_FATAL :    0
TEST DONE
Azvya Education Pvt. Ltd.VLSI Mentor
hang.log
xcelium: *N,RUNCMD: run -f cmd.f +UVM_TESTNAME=axi_burst_test
Simulation random seed = 904113
UVM_INFO @ 0: reporter [RNTST] Running test axi_burst_test...
UVM_INFO drv.sv(60) @ 141800: uvm_test_top.env.agt.drv [WR] waiting
Azvya Education Pvt. Ltd.VLSI Mentor
norun.log
xmelab: *E,CUVWSP: module worklib.tb_top not found
xcelium: *F,NOELAB: elaboration failed
Azvya Education Pvt. Ltd.VLSI Mentor
parser.py
import re

UVM = re.compile(
    r"^UVM_(?P<sev>INFO|WARNING|ERROR|FATAL)"
    r"(?:\s+(?P<file>\S+\.sv)\((?P<line>\d+)\))?"
    r"\s+@\s+(?P<time>\d+):\s+(?P<scope>\S+)"
    r"(?:\s+\[(?P<id>[^\]]+)\])?\s*(?P<msg>.*)$"
)
TALLY = re.compile(r"^UVM_(?P<sev>INFO|WARNING|ERROR|FATAL)\s*:\s*(?P<n>\d+)\s*$")
SVA = re.compile(r"Assertion (?P<name>[\w.]+) failed at time (?P<t>\d+)")
TESTNAME = re.compile(r"\+UVM_TESTNAME=(\w+)")
BANNER = re.compile(r"\[RNTST\] Running test (\w+)")
FINISH = re.compile(r"TEST DONE|Simulation complete|\$finish")
SEED_PATTERNS = [re.compile(r"random seed\s*=\s*(\d+)"),
                 re.compile(r"\+ntb_random_seed=(\d+)")]

SEVERITIES = ["INFO", "WARNING", "ERROR", "FATAL"]
BEFORE = 2
AFTER = 1


def new_record(path):
    """Every key, present from the start. Chapter 20.6's rule."""
    return {"log": path, "test": None, "seed": None,
            "errors": 0, "fatals": 0, "warnings": 0, "assertions": 0,
            "first_failure": None, "first_failure_time": None,
            "context": [], "context_marker": None,
            "started": False, "finished": False, "unparsed": 0,
            "counts_agree": None, "status": "UNKNOWN", "runtime_s": None}


def parse_sim_log(path):
    """One UVM simulation log, one pass, one record. Raises FileNotFoundError."""
    record = new_record(path)
    counted = dict.fromkeys(SEVERITIES, 0)
    reported = {}
    recent, after, pending = [], [], 0

    def note_failure(line, time):
        if record["first_failure"] is None:
            record["first_failure"] = line
            record["first_failure_time"] = time
            record["context"] = recent[-BEFORE:]
            record["context_marker"] = len(record["context"])
            return AFTER
        return 0

    with open(path, encoding="utf-8") as handle:
        for raw in handle:
            line = raw.rstrip()

            if pending:
                after.append(line)
                pending -= 1

            if record["test"] is None:
                m = TESTNAME.search(line) or BANNER.search(line)
                if m is not None:
                    record["test"] = m.group(1)
            if record["seed"] is None:
                for pattern in SEED_PATTERNS:
                    m = pattern.search(line)
                    if m is not None:
                        record["seed"] = int(m.group(1))
                        break
            if BANNER.search(line):
                record["started"] = True
            if FINISH.search(line):
                record["finished"] = True

            m = UVM.match(line)
            if m is not None:
                counted[m["sev"]] += 1
                if m["sev"] in ("ERROR", "FATAL"):
                    key = "errors" if m["sev"] == "ERROR" else "fatals"
                    record[key] += 1
                    pending = note_failure(line, int(m["time"])) or pending
                elif m["sev"] == "WARNING":
                    record["warnings"] += 1
            else:
                t = TALLY.match(line)
                a = SVA.search(line)
                if t is not None:
                    reported[t["sev"]] = int(t["n"])
                elif a is not None:
                    record["assertions"] += 1
                    pending = note_failure(line, int(a["t"])) or pending
                elif line.strip():
                    record["unparsed"] += 1

            recent.append(line)
            if len(recent) > BEFORE:
                recent.pop(0)

    if record["first_failure"] is not None:
        record["context"] = record["context"] + [record["first_failure"]] + after
    if reported:
        record["counts_agree"] = reported == counted

    if not record["started"]:
        record["status"] = "NO_RUN"
    elif not record["finished"]:
        record["status"] = "NO_FINISH"
    elif record["errors"] or record["fatals"] or record["assertions"]:
        record["status"] = "FAIL"
    else:
        record["status"] = "PASS"
    return record
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from parser import parse_sim_log

LOGS = ["smoke.log", "reset.log", "hang.log", "norun.log"]

print(f"{'log':<11} {'status':<10} {'test':<16} {'seed':>8} {'err':>4} "
      f"{'agree':>6} {'ctx':>4}")
for path in LOGS:
    r = parse_sim_log(path)
    print(f"{path:<11} {r['status']:<10} {str(r['test']):<16} {str(r['seed']):>8} "
          f"{r['errors']:>4} {str(r['counts_agree']):>6} {len(r['context']):>4}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
log         status     test                 seed  err  agree  ctx
smoke.log   FAIL       axi_smoke_test    1839274    2   True    4
reset.log   PASS       axi_reset_test     771002    0   True    0
hang.log    NO_FINISH  axi_burst_test     904113    0   None    0
norun.log   NO_RUN     None                 None    0   None    0

Four logs, four statuses, every field filled or honestly None.

Read the agree column: True on the two logs that contain a report summary, and None on the two that do not — Chapter 20.7's self-check, with None distinguishing we checked and agreed from there was nothing to check against. A False there would mean the parser and the simulator disagree, which Chapter 20.7 said is a parser bug until proved otherwise.

And hang.log keeps its test name and seed. A hung run is still reproducible, which is often the most useful thing about the record.

5. One Record in Full

Azvya Education Pvt. Ltd.VLSI Mentor
smoke.log
xcelium: *N,RUNCMD: run -f cmd.f +UVM_TESTNAME=axi_smoke_test
Simulation random seed = 1839274
UVM_INFO @ 0: reporter [RNTST] Running test axi_smoke_test...
UVM_INFO drv.sv(60) @ 141700: uvm_test_top.env.agt.drv [WR] issuing write
UVM_ERROR drv.sv(88) @ 141900: uvm_test_top.env.agt.drv [WR] bad handshake
UVM_INFO drv.sv(92) @ 142000: uvm_test_top.env.agt.drv [WR] retrying
UVM_ERROR drv.sv(104) @ 180400: uvm_test_top.env.agt.drv [WR] timeout
--- UVM Report Summary ---
UVM_INFO :    3
UVM_WARNING :    0
UVM_ERROR :    2
UVM_FATAL :    0
TEST DONE
Azvya Education Pvt. Ltd.VLSI Mentor
parser.py
import re

UVM = re.compile(
    r"^UVM_(?P<sev>INFO|WARNING|ERROR|FATAL)"
    r"(?:\s+(?P<file>\S+\.sv)\((?P<line>\d+)\))?"
    r"\s+@\s+(?P<time>\d+):\s+(?P<scope>\S+)"
    r"(?:\s+\[(?P<id>[^\]]+)\])?\s*(?P<msg>.*)$"
)
TALLY = re.compile(r"^UVM_(?P<sev>INFO|WARNING|ERROR|FATAL)\s*:\s*(?P<n>\d+)\s*$")
SVA = re.compile(r"Assertion (?P<name>[\w.]+) failed at time (?P<t>\d+)")
TESTNAME = re.compile(r"\+UVM_TESTNAME=(\w+)")
BANNER = re.compile(r"\[RNTST\] Running test (\w+)")
FINISH = re.compile(r"TEST DONE|Simulation complete|\$finish")
SEED_PATTERNS = [re.compile(r"random seed\s*=\s*(\d+)")]

SEVERITIES = ["INFO", "WARNING", "ERROR", "FATAL"]
BEFORE = 2
AFTER = 1


def parse_sim_log(path):
    record = {"log": path, "test": None, "seed": None,
              "errors": 0, "fatals": 0, "warnings": 0, "assertions": 0,
              "first_failure": None, "first_failure_time": None,
              "context": [], "context_marker": None,
              "started": False, "finished": False, "unparsed": 0,
              "counts_agree": None, "status": "UNKNOWN", "runtime_s": None}
    counted = dict.fromkeys(SEVERITIES, 0)
    reported = {}
    recent, after, pending = [], [], 0

    with open(path, encoding="utf-8") as handle:
        for raw in handle:
            line = raw.rstrip()
            if pending:
                after.append(line)
                pending -= 1
            if record["test"] is None:
                m = TESTNAME.search(line) or BANNER.search(line)
                if m is not None:
                    record["test"] = m.group(1)
            if record["seed"] is None:
                for pattern in SEED_PATTERNS:
                    m = pattern.search(line)
                    if m is not None:
                        record["seed"] = int(m.group(1))
                        break
            if BANNER.search(line):
                record["started"] = True
            if FINISH.search(line):
                record["finished"] = True

            m = UVM.match(line)
            if m is not None:
                counted[m["sev"]] += 1
                if m["sev"] in ("ERROR", "FATAL"):
                    record["errors" if m["sev"] == "ERROR" else "fatals"] += 1
                    if record["first_failure"] is None:
                        record["first_failure"] = line
                        record["first_failure_time"] = int(m["time"])
                        record["context"] = recent[-BEFORE:]
                        record["context_marker"] = len(record["context"])
                        pending = AFTER
                elif m["sev"] == "WARNING":
                    record["warnings"] += 1
            else:
                t = TALLY.match(line)
                if t is not None:
                    reported[t["sev"]] = int(t["n"])
                elif line.strip():
                    record["unparsed"] += 1

            recent.append(line)
            if len(recent) > BEFORE:
                recent.pop(0)

    if record["first_failure"] is not None:
        record["context"] = record["context"] + [record["first_failure"]] + after
    if reported:
        record["counts_agree"] = reported == counted
    if not record["started"]:
        record["status"] = "NO_RUN"
    elif not record["finished"]:
        record["status"] = "NO_FINISH"
    elif record["errors"] or record["fatals"] or record["assertions"]:
        record["status"] = "FAIL"
    else:
        record["status"] = "PASS"
    return record
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from parser import parse_sim_log

record = parse_sim_log("smoke.log")

for key in sorted(record):
    if key == "context":
        continue
    print(f"  {key:<20} {record[key]!r}")

print()
print("  context:")
for index, line in enumerate(record["context"]):
    marker = ">>>" if index == record["context_marker"] else "   "
    print(f"    {marker} {line[:50]}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  assertions           0
  context_marker       2
  counts_agree         True
  errors               2
  fatals               0
  finished             True
  first_failure        'UVM_ERROR drv.sv(88) @ 141900: uvm_test_top.env.agt.drv [WR] bad handshake'
  first_failure_time   141900
  log                  'smoke.log'
  runtime_s            None
  seed                 1839274
  started              True
  status               'FAIL'
  test                 'axi_smoke_test'
  unparsed             4
  warnings             0

  context:
        UVM_INFO @ 0: reporter [RNTST] Running test axi_sm
        UVM_INFO drv.sv(60) @ 141700: uvm_test_top.env.agt
    >>> UVM_ERROR drv.sv(88) @ 141900: uvm_test_top.env.ag
        UVM_INFO drv.sv(92) @ 142000: uvm_test_top.env.agt

Everything a triage report needs, from one pass over one file.

unparsed: 4 on a perfectly healthy log is worth explaining rather than treating as a defect. Those four are the xcelium RUNCMD line, the seed line, the --- UVM Report Summary --- banner and TEST DONE — real lines the parser extracts facts from but does not classify as messages. Chapter 20.6 Section 4 said the number only means something as a ratio: four out of twelve is normal, twelve out of twelve is a format change.

6. The Field the Log Cannot Fill

The canonical purpose lists runtime among the record's contents, and runtime_s is in the record as None. That is deliberate.

Wall-clock runtime is not in the log. The log contains simulation time — @ 180400 — which is a count of picoseconds in a simulated world and has no fixed relationship to seconds on a wall. A test that simulates 200 microseconds might take nine seconds or four hours depending on the design size, the machine and whether waveform dumping was on.

Deriving one from the other would be a fabrication, and a precise-looking one. So the record reserves the key and leaves it for whoever ran the simulation:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def parsed(path):
    """Stand-in for parse_sim_log, so this example needs no log file."""
    return {"log": path, "test": "axi_smoke_test", "status": "FAIL",
            "errors": 2, "runtime_s": None}


record = parsed("smoke.log")
print("from the log alone:", record["runtime_s"])

record["runtime_s"] = 143.2
print("after the runner  :", record["runtime_s"])

print()
print("reproduce:", f"run_sim.py --test {record['test']} --seed 1839274")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from the log alone: None
after the runner  : 143.2

reproduce: run_sim.py --test axi_smoke_test --seed 1839274

The same applies to the runner's exit status and to Chapter 20.12's timeout reason: facts the launcher has and the log does not. Chapter 25.6 is where the runner fills them in, and the record is designed so it can — a reserved key with an honest None rather than a missing key or an invented number.

7. A Dict, Not a Dataclass

Chapter 16.15 introduced dataclasses and Chapter 16.16 gave the test for when a class earns its place. Applied here, the answer is a dict, and the reasoning is worth being explicit about because the opposite choice is defensible later.

The record has no behaviour. It is seventeen values and no methods. Chapter 16.16 Section 3 said the test is behaviour, and there is none.

It goes straight into formats that want a mapping. Chapter 21.2's csv.DictWriter takes a dict; json.dump takes a dict. A dataclass would be converted back at the boundary.

Its fields change as a flow grows. Chapter 25.6 adds the runner's facts, Chapter 26.6 adds the job id. With a dict that is an assignment; with a dataclass it is an edit to a shared type that every other consumer must be rebuilt against.

It is data in transit, not a thing with an identity. Nobody asks whether two records are the same object.

What would change the answer: the moment the record grows methods — is_reproducible(), summary_line(), worse_than(other) — a dataclass stops the methods being scattered across the modules that use it. That is Chapter 25.6's situation and not this one.

8. What It Deliberately Does Not Contain

Four things a reader might expect, and why each is absent:

Every failing line. Only the first, plus context. A log with forty thousand errors would otherwise make the record bigger than the summary it feeds, and Chapter 20.10 Section 6 argued that a report nobody opens is a report that does not work. The log path is in the record; everything else is one less away.

The full severity tallies as a sub-dict. counted exists inside the parser and only counts_agree comes out. The four counts the caller needs are already top-level fields, and a nested dict would complicate every CSV writer downstream for no new information.

The assertion names. Chapter 20.9 parsed them and the record keeps only the count, because a list of them is unbounded. If a flow needs them, they belong in a second record type — one per assertion failure, as Chapter 20.9 Section 4 produced — not crammed into this one.

A pass/fail boolean. status has four values and a boolean would throw away two of them. Chapter 20.11 Section 1's three-passes-out-of-four is what a boolean costs, and any consumer wanting one can write record["status"] == "PASS" for itself.

The pattern across all four: the record carries what is bounded and summarises what is not. That is Chapter 15.7 Section 5's bounded summary, applied one level up — not to the lines of a log, but to the shape of a result.

9. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def new_record(path):
    return {"log": path, "test": None, "seed": None, "errors": 0,
            "first_failure": None, "status": "UNKNOWN", "runtime_s": None}


def classify(record):
    if not record.get("started"):
        return "NO_RUN"
    if not record.get("finished"):
        return "NO_FINISH"
    return "FAIL" if record["errors"] else "PASS"


a = new_record("a.log")
b = new_record("b.log")

# A
print("A:", sorted(a) == sorted(b))

# B
print("B:", a["seed"], a["runtime_s"])

# C
a.update({"started": True, "finished": True, "errors": 0})
print("C:", classify(a))

# D
b.update({"started": True, "finished": False, "errors": 7})
print("D:", classify(b))

# E
a["errrors"] = 99
print("E:", a["errors"], len(a))

# F
try:
    print(a["runtime"])
except KeyError as exc:
    print("F: KeyError", exc)

# G
print("G:", a["runtime_s"] is None, a["runtime_s"] == 0)

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: True
B: None None
C: PASS
D: NO_FINISH
E: 0 10
F: KeyError 'runtime'
G: True False

A — two records from different logs have the same keys. That is the property every consumer relies on, and it is one line to check.

B — unfilled fields are None, not zero and not absent.

C and D — the verdict comes from facts the record already holds, and D is NO_FINISH despite seven errors. Chapter 20.11's ordering.

E — the misspelled key was accepted silently, errors is untouched at 0, and the record now carries ten keys where new_record created seven. The dict's real cost, and the reason new_record exists.

F — reading a key that was never created raises, which is how the Section E typo eventually surfaces — somewhere else, later.

G — None is not 0. A consumer averaging runtime_s must skip the Nones rather than treat them as zero-second runs.

10. Exercises

Exercise 1 — the product. Write down the four groups of field for a report format in your own flow, and one field in each.

Exercise 2 — earn every field. List the fields of your own result record and name the reason each exists. Delete any you cannot justify.

Exercise 3 — one pass. Write parse_sim_log for your format. Confirm it reads the file once and name every piece of state it holds.

Exercise 4 — bounded. Say which of your state variables could grow with the log, and what stops each one.

Exercise 5 — four logs. Run your parser over a passing log, a failing one, a truncated one and a log from a run that never started. Print one row each.

Exercise 6 — the agree column. Produce a record with counts_agree of True, one with None, and say what a False would mean.

Exercise 7 — a hung run is reproducible. Confirm your NO_FINISH record still carries its test and seed, and say why that matters.

Exercise 8 — the unparsed ratio. Report unparsed and the total line count for a healthy log. Choose a ratio that would make you suspicious.

Exercise 9 — the reserved key. Add runtime_s: None and fill it from outside. Say what omitting the key would cost, and what filling it with 0 would cost.

Exercise 10 — dict or dataclass. Apply Chapter 16.16's test to your record and justify the answer in three sentences. Then name the change that would flip it.

Exercise 11 — the silent typo. Misspell a key on write and show that nothing raises. Then show where it surfaces.

Exercise 12 — what you left out. Name one thing you considered putting in the record and did not. Say what is unbounded about it and where it should live instead.

11. Summary

  • The record is the module's product, and Chapters 21.1, 25.6, 26.6 and 30.2 are all downstream of its shape.
  • Four groups: identity, counts, evidence, verdict — and only the verdict is policy.
  • Counts and evidence change when the tool changes; the verdict changes when the project does.
  • Every field names the chapter that earned it. A field with no chapter behind it does not go in.
  • Every key is present on every log, built complete by new_record before the loop starts.
  • One pass, bounded state: a ring buffer, two count dicts, a pending countdown. Nothing grows with the log.
  • counts_agree is True, False or None — agreed, disagreed, or there was no summary block to check against.
  • A hung run keeps its test and seed, so it is still reproducible.
  • unparsed on a healthy log is not zero. It only means something as a ratio.
  • runtime_s is None because the log does not contain it — simulation time is not wall time, and deriving one from the other is a precise-looking fabrication.
  • A reserved key is a promise: omitting it gives a KeyError on unenriched records, and filling it with 0 puts a fake value into an average.
  • A dict, not a dataclass — no behaviour, goes straight into csv.DictWriter and json.dump, and gains fields as the flow grows.
  • The dict's cost is the silent typo on write, which new_record turns into a KeyError on read.
  • The record carries what is bounded and summarises what is not — the first failure rather than all of them, a count rather than a list of assertion names, four statuses rather than a boolean.

That completes the parser. Module 21 takes this record and gives it somewhere to live: a result table other tools can read, a configuration humans can edit, and the formats a flow exchanges data in.

Where this fits

Part of the Python curriculum.