Skip to content
VLSI Mentor

Python Part II · Module 29 · Coverage Automation

Parsing Coverage Reports Across Tools

Module 28 parsed reports whose headline was a single signed number. A coverage report is harder in a way that is easy to miss: it carries dozens of numbers, each belonging to a different metric at a different scope, and the one thing every layout agrees on is the per cent sign. That is exactly the character a naive pattern anchors to, and anchoring there produces a parser that succeeds on every line, returns the right percentages, and keys half of them to the wrong name — while silently discarding every group that reached a hundred.

Advanced20 min readPythonPart IIAutomationCoverageReport Parsing

Part II · Module 29 · Chapter 29.1 · Parsing Coverage Reports Across Tools

1. Four Things a Coverage Number Needs

A timing report's headline is one number with one meaning. A coverage number is meaningless until four things are attached to it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
REQUIRED = [
    ("metric", "statement, branch, toggle, functional — 29.2 separates them"),
    ("scope", "which instance or covergroup, and at which level of hierarchy"),
    ("hits / total", "the pair the percentage was rendered from — §6"),
    ("provenance", "which report file, in which layout, from which tool"),
]

print(f"{'attribute':<14} why it has to travel with the number")
for name, why in REQUIRED:
    print(f"{name:<14} {why}")

print()
print("fields that identify a number:", len(REQUIRED))
print("a bare 94.5 answers:", "none of the four")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
attribute      why it has to travel with the number
metric         statement, branch, toggle, functional — 29.2 separates them
scope          which instance or covergroup, and at which level of hierarchy
hits / total   the pair the percentage was rendered from — §6
provenance     which report file, in which layout, from which tool

fields that identify a number: 4
a bare 94.5 answers: none of the four

A single 94.5 floating in a dashboard cell is the thing this module exists to prevent. Every later chapter consumes the record, not the number: Chapter 29.2 gates per metric, Chapter 29.3 rolls up per scope, Chapter 29.4 needs the pair, and Chapter 29.6 has to show a reviewer where the figure came from.

2. The Annotation

The canonical annotation for this chapter is a single line:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
re.search(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%", line)

It is the pattern almost everybody writes first, and it is worth writing first, because it works:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re

PAT = re.compile(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%")

line = "  alu_cov        95.50%"
m = PAT.search(line)
print("name:", m.group("name"))
print("pct :", m.group("pct"))
print("as a float:", float(m.group("pct")))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
name: alu_cov
pct : 95.50
as a float: 95.5

Two named groups, one re.search, and the row is parsed. Named groups are Chapter 19.9's device and they are the right choice here — m.group("pct") survives somebody inserting a column in a way m.group(2) does not.

The pattern is also wrong in three separate ways, each of which is silent, and each of which is a different lesson. The next two sections take them in order of how much damage they do.

3. Three Reports, Three Layouts

Here are the three shapes a coverage report arrives in. The first is a score-first summary table:

Azvya Education Pvt. Ltd.VLSI Mentor
cov_a.rpt
Total Coverage Summary
SCORE   LINE    COND    TOGGLE  FSM     ASSERT
 88.21   94.50   81.33   61.07   90.00  100.00

Hierarchical coverage for top-level instances
SCORE   LINE    COND    TOGGLE  NAME
 94.12   98.00   91.10   93.26  tb.dut.alu
 71.40   82.30   64.90   67.00  tb.dut.dma
 99.80   99.90   99.70   99.80  tb.dut.regfile

The second is a name-first covergroup list with its own counts:

Azvya Education Pvt. Ltd.VLSI Mentor
cov_b.rpt
Covergroup                      Hits    Goal    Coverage
cg_axi_burst                     512     512     100.00%
cg_axi_len                       380     512      74.22%
cg_fifo_level                      64      64     100%
cg_err_inject                      12      40      30.00%

The third is a key/value block that gives the numerator and denominator outright:

Azvya Education Pvt. Ltd.VLSI Mentor
cov_c.rpt
instance : tb.dut.regfile
  statement : 1890 / 2000 ( 94.50 % )
  branch    :  712 /  800 ( 89.00 % )
  toggle    :  410 /  672 ( 61.01 % )
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from pathlib import Path

for name in ("cov_a.rpt", "cov_b.rpt", "cov_c.rpt"):
    text = Path(name).read_text(encoding="utf-8")
    lines = text.splitlines()
    print(f"{name}  lines={len(lines):<3} "
          f"has_pct_sign={'%' in text} "
          f"name_column_first={not lines[2].strip()[0].isdigit() if len(lines) > 2 else None}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cov_a.rpt  lines=9   has_pct_sign=False name_column_first=False
cov_b.rpt  lines=5   has_pct_sign=True name_column_first=True
cov_c.rpt  lines=4   has_pct_sign=True name_column_first=True

Read the first column of that output again, because it is not what the section was building towards. cov_a.rpt contains no per cent sign at all. A score-first summary table prints bare numbers under a SCORE header, and the unit lives in the column heading rather than on every cell.

So the annotation does not merely mis-key that layout. It finds nothing in it:

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

PAT = re.compile(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%")

Path("cov_a.rpt").write_text(
    "Total Coverage Summary\n"
    "SCORE   LINE    COND    TOGGLE  FSM     ASSERT\n"
    " 88.21   94.50   81.33   61.07   90.00  100.00\n"
    "\n"
    "Hierarchical coverage for top-level instances\n"
    "SCORE   LINE    COND    TOGGLE  NAME\n"
    " 94.12   98.00   91.10   93.26  tb.dut.alu\n"
    " 71.40   82.30   64.90   67.00  tb.dut.dma\n"
    " 99.80   99.90   99.70   99.80  tb.dut.regfile\n", encoding="utf-8")

text = Path("cov_a.rpt").read_text(encoding="utf-8")
hits = list(PAT.finditer(text))

print("lines in the report      :", len(text.splitlines()))
print("numbers a human can read :", 24)
print("matches the pattern found:", len(hits))
print()
print("what the caller sees:", "an empty list")
print("what it means       :", "indistinguishable from a report with no coverage")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
lines in the report      : 9
numbers a human can read : 24
matches the pattern found: 0

what the caller sees: an empty list
what it means       : indistinguishable from a report with no coverage

Twenty-four numbers in the file and zero of them extracted — and the caller receives an empty list, which is exactly what it would receive from a report of a design with no coverage at all. An empty parse is the one failure mode that cannot be distinguished from a legitimate result, which is why Section 5's percent-bearing-line count is not optional: a parser that reports "I found 0 numbers in a 9-line report" is debuggable, and one that returns [] is not.

Three layouts, then, and they do not even agree on the per cent sign — the character the annotation anchors to is absent from a third of them. Chapter 28.1 Section 3 made the same observation about timing reports; coverage reports are worse, because the layout varies between metrics within one tool, not just between tools, and the unit may live in a heading rather than beside the value.

4. \S+ Takes the Wrong Column

re.search scans left to right and returns the leftmost position at which the whole pattern matches contiguously. That second word does all the damage:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re

PAT = re.compile(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%")

ROWS = [
    "  alu_cov        95.50%",
    "cg_axi_len                       380     512      74.22%",
    "  TOTAL COVERAGE        95.50%",
    "  axi_cov  512 480  93.75%",
]

for row in ROWS:
    m = PAT.search(row)
    got = m.group("name") if m else None
    print(f"name={str(got):<10} from {row.strip()!r}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
name=alu_cov    from 'alu_cov        95.50%'
name=512        from 'cg_axi_len                       380     512      74.22%'
name=COVERAGE   from 'TOTAL COVERAGE        95.50%'
name=480        from 'axi_cov  512 480  93.75%'

Three of four rows are keyed wrongly, and not one of them raised anything.

Trace the second row, because the mechanism is worth owning. re.search tries to match starting at c of cg_axi_len: \S+ greedily takes cg_axi_len, \s+ takes the spaces, and then \d+\.\d+% has to match 380 — it does not, because after 380 comes a space, not a dot. Backtracking inside \S+ cannot help, because every shorter prefix leaves a non-space character where \s+ needs whitespace. So the engine abandons that start position and tries the next, and the next, until it starts at 512: \S+ takes 512, \s+ takes the spaces, 74.22% matches. The pattern found the only place it could match, and that place put the name group on the Goal column.

The third row is the same mechanism with a different cause: \S+ cannot span the space in TOTAL COVERAGE, so the match starts at the second word.

The fix is to say what the line actually looks like rather than what is near the percentage:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re

ROWS = [
    "cg_axi_burst                     512     512     100.00%",
    "cg_axi_len                       380     512      74.22%",
    "cg_err_inject                      12      40      30.00%",
]

GROUP_ROW = re.compile(
    r"^(?P<name>\w+)\s+(?P<hits>\d+)\s+(?P<goal>\d+)\s+(?P<pct>\d+(?:\.\d+)?)%\s*$"
)

for row in ROWS:
    m = GROUP_ROW.match(row)
    print(f"{m.group('name'):<16} {m.group('hits'):>5} / {m.group('goal'):<5} {m.group('pct')}%")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cg_axi_burst       512 / 512   100.00%
cg_axi_len         380 / 512   74.22%
cg_err_inject       12 / 40    30.00%

Four differences, and every one of them is load-bearing: ^ and $ anchor the row so a partial match is a failure rather than a silent shift; \w+ for the name cannot wander into a number; the count columns are named and captured instead of skipped, which is Section 6; and re.match with an anchored pattern makes a non-conforming row return None loudly instead of matching somewhere unintended.

5. \d+\.\d+ Drops Every Closed Group

The third defect is the one that does genuine damage, and it is visible only if you look at which rows produced nothing:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re

PAT = re.compile(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%")

ROWS = [
    ("cg_axi_burst", "cg_axi_burst                     512     512     100.00%"),
    ("cg_fifo_level", "cg_fifo_level                      64      64     100%"),
    ("cg_err_inject", "cg_err_inject                      12      40      30.00%"),
    ("dma_cov", "  dma_cov      88.0 %"),
]

parsed, dropped = [], []
for label, row in ROWS:
    m = PAT.search(row)
    (parsed if m else dropped).append(label)

print("parsed :", parsed)
print("dropped:", dropped)
print()
print("rows presented:", len(ROWS))
print("rows the parser will report on:", len(parsed))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
parsed : ['cg_axi_burst', 'cg_err_inject']
dropped: ['cg_fifo_level', 'dma_cov']

rows presented: 4
rows the parser will report on: 2

cg_fifo_level is at 100% and the pattern cannot see it, because \d+\.\d+ requires a decimal point and the report wrote 100%. dma_cov is lost to a space before the per cent sign.

Now price the consequence, because this is the defect worth remembering:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re

PAT = re.compile(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%")
FIXED = re.compile(r"^(?P<name>\w+)\s+(?P<pct>\d+(?:\.\d+)?)\s*%", re.M)

REPORT = """cg_axi_burst   100%
cg_axi_len      74.22%
cg_fifo_level  100%
cg_err_inject   30.00%
cg_reset        100%
cg_wr_rd        56.80%
"""

naive = [float(m.group("pct")) for m in
         (PAT.search(l) for l in REPORT.splitlines()) if m]
fixed = [float(m.group("pct")) for m in FIXED.finditer(REPORT)]

print("groups in the report:", len(REPORT.splitlines()))
print("naive pattern saw   :", len(naive), naive)
print("fixed pattern saw   :", len(fixed), fixed)
print()
print(f"mean over what the naive pattern saw: {sum(naive)/len(naive):.2f}%")
print(f"mean over every group              : {sum(fixed)/len(fixed):.2f}%")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
groups in the report: 6
naive pattern saw   : 3 [74.22, 30.0, 56.8]
fixed pattern saw   : 6 [100.0, 74.22, 100.0, 30.0, 100.0, 56.8]

mean over what the naive pattern saw: 53.67%
mean over every group              : 76.84%

53.67% against a true 76.84%. The pattern discarded exactly the rows that were fully covered, because those are the rows a tool is most likely to print without a decimal, and the reported figure came out 23 points too low.

Think about which direction that error points. A parser that over-reports coverage gets found immediately — somebody asks why a block nobody has written tests for is at 94%. A parser that under-reports coverage sends a team to write tests for groups that are already closed, and the only symptom is engineers reporting that the number will not move. An error that makes you look worse than you are is the one that survives longest in a flow, which is the same asymmetry Chapter 20.7 described for a missed UVM_ERROR, pointing the other way.

The guard is cheap and belongs in every report parser:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re

FIXED = re.compile(r"^(?P<name>\w+)\s+(?P<pct>\d+(?:\.\d+)?)\s*%", re.M)

REPORT = """cg_axi_burst   100%
cg_axi_len      74.22%
cg_fifo_level  100%
cg_err_inject   30.00%
Covergroup summary follows
cg_reset        100%
"""

candidates = [l for l in REPORT.splitlines() if "%" in l]
matched = [m.group("name") for m in FIXED.finditer(REPORT)]

print("lines containing a per cent sign:", len(candidates))
print("lines the pattern parsed        :", len(matched))
print("unparsed but percent-bearing    :",
      [l.strip() for l in candidates if not FIXED.match(l)])
print()
print("accounted for:", len(matched) == len(candidates))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
lines containing a per cent sign: 5
lines the pattern parsed        : 5
unparsed but percent-bearing    : []

accounted for: True

Count the lines that should have parsed and compare against the ones that did. This is Chapter 20.6's unparsed counter applied to a report instead of a log, and it is the only mechanism that turns a silent drop into a visible number. Note that the Covergroup summary follows line contains no per cent sign and so is correctly not a candidate.

6. A Percentage Is Not the Number You Want

The annotation captures pct and discards the counts beside it. That is the deepest of the three defects, because nothing about it looks like a defect:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
RUNS = [
    ("Monday", 900, 1000),
    ("Friday", 1056, 1200),
]

for day, hits, total in RUNS:
    print(f"{day:<8} {hits:>5} / {total:<5} = {100.0 * hits / total:.2f}%")

mon, fri = RUNS[0], RUNS[1]
pct_mon = 100.0 * mon[1] / mon[2]
pct_fri = 100.0 * fri[1] / fri[2]

print()
print(f"percentage moved: {pct_fri - pct_mon:+.2f} points")
print(f"bins covered    : {fri[1] - mon[1]:+d}")
print(f"bins coverable  : {fri[2] - mon[2]:+d}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
Monday     900 / 1000  = 90.00%
Friday    1056 / 1200  = 88.00%

percentage moved: -2.00 points
bins covered    : +156
bins coverable  : +200

The percentage fell two points in a week during which the team covered 156 more bins than before. Somebody merged a block with 200 new coverable bins, and the denominator moved faster than the numerator.

A parser that kept only pct has thrown away the only evidence that could distinguish those two readings, and no amount of cleverness downstream can recover it. Chapter 29.4 is built entirely on this pair; Chapter 29.2 needs it to gate without being fooled by rounding; Chapter 29.5 needs the denominator to tell a hole from an unreachable bin. Capture hits and total wherever the report prints them, and derive the percentage yourself.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
SPEC = [
    ("cov_a.rpt", "score-first table", False, "percentage only"),
    ("cov_b.rpt", "covergroup list", True, "Hits and Goal columns"),
    ("cov_c.rpt", "key/value block", True, "numerator / denominator"),
]

print(f"{'report':<12} {'layout':<20} {'has pair':<9} source")
for name, layout, pair, src in SPEC:
    print(f"{name:<12} {layout:<20} {str(pair):<9} {src}")

print()
print("reports that give the pair:", sum(1 for s in SPEC if s[2]), "of", len(SPEC))
print("so the record needs:", "an optional pair, and a percentage that is always present")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
report       layout               has pair  source
cov_a.rpt    score-first table    False     percentage only
cov_b.rpt    covergroup list      True      Hits and Goal columns
cov_c.rpt    key/value block      True      numerator / denominator

reports that give the pair: 2 of 3
so the record needs: an optional pair, and a percentage that is always present

Not every report gives the pair, so the record carries it as optional — exactly the pattern Chapter 28.5 Section 4 used for an unavailable metric. A number with no denominator is still usable; it just cannot participate in Chapter 29.4's comparison, and the record has to say so rather than inventing a total.

7. Per-Instance and Per-Module Are Different Scopes

cov_a.rpt reports three instances under tb.dut. The same RTL module can appear at several instance paths, and the two scopes answer different questions:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
INSTANCES = [
    ("tb.dut.fifo_tx", "fifo", 61, 80),
    ("tb.dut.fifo_rx", "fifo", 74, 80),
    ("tb.dut.alu", "alu", 196, 200),
]

print(f"{'instance':<18} {'module':<8} {'hits':>5} {'total':>6}  pct")
for path, module, hits, total in INSTANCES:
    print(f"{path:<18} {module:<8} {hits:>5} {total:>6}  {100.0*hits/total:.2f}%")

by_module = {}
for _, module, hits, total in INSTANCES:
    h, t = by_module.get(module, (0, 0))
    by_module[module] = (h + hits, t + total)

print()
print("merged by module:")
for module, (h, t) in sorted(by_module.items()):
    print(f"  {module:<8} {h:>5} / {t:<5} = {100.0*h/t:.2f}%")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
instance           module    hits  total  pct
tb.dut.fifo_tx     fifo        61     80  76.25%
tb.dut.fifo_rx     fifo        74     80  92.50%
tb.dut.alu         alu        196    200  98.00%

merged by module:
  alu        196 / 200   = 98.00%
  fifo       135 / 160   = 84.38%

Both views are correct and they support different decisions. The per-module number answers "is this RTL exercised anywhere?", which is the right question for code coverage of a reusable block — a line covered in fifo_rx is a line that has been executed, and merging is how tools treat code coverage by default. The per-instance number answers "is this instantiation exercised?", which is the right question when one instance is configured differently: fifo_tx at 76% against fifo_rx at 92% says the transmit path has traffic the receive path does not, and merging them to 84% hides that entirely.

So the record needs scope as two fields, not one, and the merge must never be implicit:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass


@dataclass(frozen=True)
class Scope:
    instance: str
    module: str
    merged: bool


s1 = Scope("tb.dut.fifo_tx", "fifo", False)
s2 = Scope(instance="", module="fifo", merged=True)

print("per-instance:", s1)
print("per-module  :", s2)
print()
print("a merged record has no instance path:", s2.instance == "")
print("and says so explicitly              :", s2.merged)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
per-instance: Scope(instance='tb.dut.fifo_tx', module='fifo', merged=False)
per-module  : Scope(instance='', module='fifo', merged=True)

a merged record has no instance path: True
and says so explicitly              : True

A merged flag looks like bookkeeping until somebody compares a merged number against a per-instance one and reports a regression that is purely an artefact of the scope changing. Chapter 28.3 Section 7 made the same demand of synthesis numbers: two figures are comparable only when they were produced the same way.

8. Recording Where Each Number Came From

The canonical purpose for this chapter ends with a requirement that is easy to skip: record which tool and report format each number came from. Here is why it is in the purpose.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass, asdict
from typing import Optional


@dataclass(frozen=True)
class CoverageNumber:
    metric: str
    scope: str
    pct: float
    hits: Optional[int]
    total: Optional[int]
    layout: str
    report: str


n1 = CoverageNumber("functional", "cg_axi_len", 74.22, 380, 512, "covergroup-list", "cov_b.rpt")
n2 = CoverageNumber("toggle", "tb.dut.dma", 67.00, None, None, "score-table", "cov_a.rpt")

for n in (n1, n2):
    d = asdict(n)
    print(f"{d['metric']:<11} {d['scope']:<12} {d['pct']:>6.2f}%  "
          f"pair={'yes' if d['hits'] is not None else 'no ':<3} "
          f"layout={d['layout']:<16} {d['report']}")

print()
print("can take part in a run-over-run delta:", n1.hits is not None, n2.hits is not None)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
functional  cg_axi_len    74.22%  pair=yes layout=covergroup-list  cov_b.rpt
toggle      tb.dut.dma    67.00%  pair=no  layout=score-table      cov_a.rpt

can take part in a run-over-run delta: True False

The layout field is the one people drop, and it is the one that pays for itself. When a number looks wrong, the first question is which parsing path produced it — and with three layouts in one flow, that question is unanswerable from a bare percentage. The report field is Chapter 28.1 Section 12's detail_path under another name: extract what a program compares, point at what a person reads.

9. The Parser, Assembled

Three layouts, one record type, one dispatch:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

GROUP_ROW = re.compile(
    r"^(?P<name>\w+)\s+(?P<hits>\d+)\s+(?P<goal>\d+)\s+(?P<pct>\d+(?:\.\d+)?)%\s*$")
KV_ROW = re.compile(
    r"^\s+(?P<metric>\w+)\s*:\s*(?P<hits>\d+)\s*/\s*(?P<total>\d+)\s*\(\s*(?P<pct>\d+(?:\.\d+)?)\s*%\s*\)")
KV_INSTANCE = re.compile(r"^instance\s*:\s*(?P<path>\S+)")


@dataclass(frozen=True)
class CoverageNumber:
    metric: str
    scope: str
    pct: float
    hits: Optional[int]
    total: Optional[int]
    layout: str
    report: str


def parse_covergroup_list(path):
    out, seen, skipped = [], 0, []
    for line in Path(path).read_text(encoding="utf-8").splitlines():
        if "%" not in line:
            continue
        seen += 1
        m = GROUP_ROW.match(line)
        if not m:
            skipped.append(line.strip())
            continue
        out.append(CoverageNumber("functional", m.group("name"), float(m.group("pct")),
                                  int(m.group("hits")), int(m.group("goal")),
                                  "covergroup-list", str(path)))
    return out, seen, skipped


def parse_kv_block(path):
    out, instance, seen, skipped = [], "", 0, []
    for line in Path(path).read_text(encoding="utf-8").splitlines():
        hit_inst = KV_INSTANCE.match(line)
        if hit_inst:
            instance = hit_inst.group("path")
            continue
        if "%" not in line:
            continue
        seen += 1
        m = KV_ROW.match(line)
        if not m:
            skipped.append(line.strip())
            continue
        out.append(CoverageNumber(m.group("metric"), instance, float(m.group("pct")),
                                  int(m.group("hits")), int(m.group("total")),
                                  "kv-block", str(path)))
    return out, seen, skipped


Path("cov_b.rpt").write_text(
    "Covergroup                      Hits    Goal    Coverage\n"
    "cg_axi_burst                     512     512     100.00%\n"
    "cg_axi_len                       380     512      74.22%\n"
    "cg_fifo_level                     64      64     100%\n"
    "cg_err_inject                     12      40      30.00%\n", encoding="utf-8")
Path("cov_c.rpt").write_text(
    "instance : tb.dut.regfile\n"
    "  statement : 1890 / 2000 ( 94.50 % )\n"
    "  branch    :  712 /  800 ( 89.00 % )\n"
    "  toggle    :  410 /  672 ( 61.01 % )\n", encoding="utf-8")

numbers = []
for path, parser in (("cov_b.rpt", parse_covergroup_list), ("cov_c.rpt", parse_kv_block)):
    got, seen, skipped = parser(path)
    numbers.extend(got)
    print(f"{path}: percent-bearing lines {seen} | parsed {len(got)} | unparsed {len(skipped)}")
    for s in skipped:
        print("   ! unparsed:", s)

print()
print(f"{'metric':<11} {'scope':<16} {'pct':>7}  hits/total  layout")
for n in numbers:
    print(f"{n.metric:<11} {n.scope:<16} {n.pct:>6.2f}%  {n.hits:>4}/{n.total:<5}  {n.layout}")

print()
print("numbers extracted:", len(numbers))
print("every number carries a denominator:", all(n.total for n in numbers))
print("derived percentages agree with the printed ones:",
      all(abs(100.0 * n.hits / n.total - n.pct) < 0.02 for n in numbers))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cov_b.rpt: percent-bearing lines 4 | parsed 4 | unparsed 0
cov_c.rpt: percent-bearing lines 3 | parsed 3 | unparsed 0

metric      scope                pct  hits/total  layout
functional  cg_axi_burst     100.00%   512/512    covergroup-list
functional  cg_axi_len        74.22%   380/512    covergroup-list
functional  cg_fifo_level    100.00%    64/64     covergroup-list
functional  cg_err_inject     30.00%    12/40     covergroup-list
statement   tb.dut.regfile    94.50%  1890/2000   kv-block
branch      tb.dut.regfile    89.00%   712/800    kv-block
toggle      tb.dut.regfile    61.01%   410/672    kv-block

numbers extracted: 7
every number carries a denominator: True
derived percentages agree with the printed ones: True

Two layouts, seven numbers, zero unparsed percent-bearing lines, and a final check that the arithmetic the report printed agrees with the arithmetic the parser derived. That last line is the cheapest real verification in the module: when a report's printed percentage disagrees with its own counts, something is wrong with either the parse or the report, and you want to know which on the night it happens rather than in a closure meeting.

Note what the dispatch does not do: it never guesses the layout from the content. Each path is parsed by the parser for its format, and the layout field records which one ran. Chapter 29.3 picks these records up and rolls them into a view a meeting can read.

10. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re

A = re.compile(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%")
B = re.compile(r"^(?P<name>\w+)\s+(?P<pct>\d+(?:\.\d+)?)%$")

# A
print("A:", A.search("cg_x  40  50  80.00%").group("name"))

# B
print("B:", A.search("cg_y  100%"))

# C
m = B.match("cg_z  100%")
print("C:", m.group("name"), m.group("pct"))

# D
print("D:", bool(B.match("  cg_w  90.0%")))

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: 50
B: None
C: cg_z 100
D: False

A — the name group landed on the Goal column, because that is the only start position from which the whole pattern matches. Section 4.

B — 100% has no decimal point, so the row vanishes. Section 5, and the reason a naive parser under-reports.

C — the optional fraction group parses 100 and the anchored name group keeps cg_z.

D — ^ means ^. The leading spaces make the anchored pattern fail, which is a reminder that anchoring a row pattern means handling the report's indentation deliberately — ^\s* where the report indents, as KV_ROW does in Section 9.

11. Exercises

Exercise 1 — reproduce the column theft. Run the annotation against a row with a count column and print name. Explain in one sentence why the engine chose that start position.

Exercise 2 — the two-word label. Find a row in your own reports whose label contains a space, and say what the annotation returns for it.

Exercise 3 — count what you dropped. Add the percent-bearing-line counter from Section 5 to a parser you already have, and report the number of unparsed lines.

Exercise 4 — which direction. Explain in two sentences why under-reporting coverage survives longer in a flow than over-reporting it.

Exercise 5 — 100% in your reports. Determine whether your tool prints 100 or 100.00, and say how you found out.

Exercise 6 — anchor a row. Rewrite the annotation as an anchored full-row pattern for one layout you own, with named count groups.

Exercise 7 — keep the pair. Take a parser that captures only a percentage and extend it to capture hits and total. Note every report where the pair is unavailable.

Exercise 8 — derive and compare. Add the check that a derived percentage agrees with the printed one, and run it over a real report.

Exercise 9 — the denominator moved. Construct two runs where coverage rises in bins and falls in per cent, and state both numbers.

Exercise 10 — scope. For one reusable block in your design, give the per-instance numbers and the merged number, and say which decision each supports.

Exercise 11 — the merged flag. Explain what goes wrong when a merged number is compared against a per-instance one, with a concrete pair of numbers.

Exercise 12 — provenance. Add layout and report fields to your coverage record and say which question each one answers.

Exercise 13 — three layouts. Write the anchored row pattern for a third layout of your own and add it to the dispatch.

Exercise 14 — a tool upgrade. Describe what you would see in your records on the night a tool changes its report layout, with the layout field present and without it.

Exercise 15 — no denominator. Decide what your record should do when a report gives only a percentage, and say why inventing a total is worse.

Exercise 16 — the empty parse. Run a pattern anchored on % against a report that has no per cent signs, and say what the caller receives.

Exercise 17 — twenty-four numbers, zero matches. Explain in two sentences why an empty result is the hardest failure mode to notice.

Exercise 18 — the unit in the heading. Find a report in your flow where the unit lives in a column heading rather than beside the value.

Exercise 19 — backtracking by hand. Trace (?P<name>\S+)\s+(?P<pct>\d+\.\d+)% through "cg_x 40 50 80.00%" and say why every start position before 50 fails.

Exercise 20 — re.match versus re.search. Replace a search with an anchored match in one of your parsers and report what stopped matching.

Exercise 21 — the indentation. Write the anchored row pattern for a report that indents its rows, and say why bare ^ is not enough.

Exercise 22 — derive and disagree. Construct a report whose printed percentage disagrees with its own counts, and say which you would trust.

Exercise 23 — two scopes, one module. For a block instantiated twice in your design, give both per-instance numbers and the merged one, and name a decision each supports.

Exercise 24 — the merged flag in a comparison. Write the check that refuses to compare a merged number with a per-instance one.

Exercise 25 — layout as a dimension. Group a week of your coverage records by layout and say what a sudden change in the grouping would mean.

12. Summary

  • A coverage number needs four things attached: metric, scope, the hits/total pair, and provenance. A bare percentage answers none of them.
  • re.search returns the leftmost position where the whole pattern matches — so a count column between the name and the percentage puts the name group on the count, silently.
  • A label containing a space loses its first word to \S+ for the same reason.
  • \d+\.\d+ requires a decimal point, so a report that prints 100% loses every fully-covered group, and the reported figure comes out too low.
  • An error that under-reports is the one that survives, because nobody investigates a number that makes them look worse.
  • Count the percent-bearing lines and compare against what parsed. A silent drop becomes a visible number.
  • Anchor the row. ^, $, \w+ for names, and named count groups; a partial match should be a failure, not a shift.
  • A percentage is a rendering of hits/total — capture the pair where the report gives it and derive the percentage yourself.
  • The percentage can fall while covered bins rise, when somebody merges new coverable code. Only the pair distinguishes the two readings.
  • Per-instance and per-module are different scopes answering different questions; a merge must be recorded, never implicit.
  • Record the layout and the report path. The layout field turns a tool upgrade from a mystery coverage movement into a GROUP BY.
  • Check the derived percentage against the printed one. It is the cheapest verification in the module.
  • A report may contain no per cent sign at all — a score-first table carries the unit in its heading, so a pattern anchored on % returns an empty list from a nine-line report.
  • An empty parse is indistinguishable from a legitimate zero-coverage result, which is why the percent-bearing-line count matters more than any single pattern.
  • re.match with an anchored pattern fails loudly; re.search with a loose one succeeds in the wrong place.
  • Anchoring means handling the report's indentation deliberately — ^\s* where rows are indented.
  • A record carries scope as two fields, instance and module, plus a merged flag that is never implicit.

The next chapter takes these records and asks what each metric actually counts — and why one threshold for all of them is the wrong shape of answer.

Where this fits

Part of the Python curriculum.