Skip to content
VLSI Mentor

Perl Part II · Module 24 · Coverage and Regression Reporting

Human-Readable Summaries From a Hash

This is where the module starts printing. The structures are built, the numbers are decided, and all that remains is a table somebody reads with one hand on a coffee cup — which is harder than it sounds, because three of the four defects in this chapter produce a table that looks right. One of them is in the sort, one in the column widths, and the last one quietly removes the denominator from the totals line.

Intermediate11 min readPerlPart IIRegression Reporting

Part II · Module 24 · Chapter 24.6 · Human-Readable Summaries From a Hash

1. The Seeds Sort Wrongly

Start with the defect that is already present in this regression's own data.

Azvya Education Pvt. Ltd.VLSI Mentor
summary.pl
use strict;
use warnings;

sub read_log {
    my ($path) = @_;
    my %r = (verdict => "UNKNOWN", errors => 0, complete => 0, budget => 0);
    open my $fh, "<", $path or return \%r;
    while (my $l = <$fh>) {
        $r{test}    = $1 if $l =~ /^TEST:\s+(\S+)/;
        $r{seed}    = $1 if $l =~ /^SEED:\s+(\d+)/;
        $r{simtime} = $1 if $l =~ /^SIMTIME:\s+(\d+)/;
        $r{budget}  = 1  if $l =~ /^UVM_FATAL\s.*\[BUDGET\]/;
        $r{msg}++        if $l =~ /^UVM_ERROR\s+\S+\(\d+\)/;
        $r{complete} = 1 if $l =~ /^\*\* Report counts by severity/;
        $r{sum}     = $1 if $l =~ /^UVM_ERROR\s*:\s*(\d+)/;
        $r{verdict} = "PASS" if $l =~ /^TEST PASSED/;
        $r{verdict} = "FAIL" if $l =~ /^TEST FAILED/;
    }
    close $fh;
    $r{errors}  = $r{complete} ? $r{sum} : ($r{msg} || 0);
    $r{verdict} = "TIMEOUT" if $r{budget} && $r{verdict} eq "UNKNOWN";
    return \%r;
}

my @recs = grep { defined $_->{test} } map { read_log($_) } sort glob("logs/*.log");

# ---- 1. the seeds are numbers, and sort is a string sort
print "==== sorting the seeds ====\n";
my %seeds; $seeds{ $_->{seed} } = 1 for @recs;
printf "sort keys          : %s\n", join " ", sort keys %seeds;
printf "sort { \$a <=> \$b }  : %s\n", join " ", sort { $a <=> $b } keys %seeds;
my @str = sort keys %seeds;
my @num = sort { $a <=> $b } keys %seeds;
printf "the two orders %s\n",
    ("@str" eq "@num") ? "agree on this data" : "DISAGREE -- 1234 sorts before 42";

# ---- 2. the column width has to come from the data
print "\n==== the column width ====\n";
my $longest = 0;
for (@recs) { $longest = length $_->{test} if length $_->{test} > $longest }
printf "longest test name: %d characters (%s)\n", $longest,
    (sort { length($b->{test}) <=> length($a->{test})
              or $a->{test} cmp $b->{test} } @recs)[0]->{test};
print "\nwith a hardcoded %-12s:\n";
for my $r (@recs[0, 8, 12]) {
    printf "  %-12s %-8s %6d\n", $r->{test}, $r->{verdict}, $r->{errors};
}
printf "\nwith a width computed from the data (%%-%ds):\n", $longest;
for my $r (@recs[0, 8, 12]) {
    printf "  %-*s %-8s %6d\n", $longest, $r->{test}, $r->{verdict}, $r->{errors};
}
print "a name longer than the field does not truncate -- it pushes every\n";
print "later column right, on that row only, which is what breaks a table\n";

# ---- 3. verdict order: alphabetical puts PASS in the middle
print "\n==== the row order ====\n";
my %RANK = (FAIL => 0, TIMEOUT => 1, UNKNOWN => 2, PASS => 3);
printf "alphabetical : %s\n", join " ", sort keys %RANK;
printf "by severity  : %s\n", join " ", sort { $RANK{$a} <=> $RANK{$b} } keys %RANK;

# ---- 4. the report an engineer reads
print "\n==== the 9am summary ====\n";
my @ordered = sort {
    $RANK{ $a->{verdict} } <=> $RANK{ $b->{verdict} }
        or $a->{test} cmp $b->{test}
        or $a->{seed} <=> $b->{seed}
} @recs;
printf "  %-*s %-6s %-8s %6s  %s\n", $longest, "TEST", "SEED", "VERDICT",
    "ERRORS", "SIMTIME";
printf "  %s\n", "-" x ($longest + 32);
for my $r (@ordered) {
    printf "  %-*s %-6s %-8s %6d  %s\n", $longest, $r->{test}, $r->{seed},
        $r->{verdict}, $r->{errors},
        defined $r->{simtime} ? "$r->{simtime} ns" : "-";
}

# ---- 5. the totals line
print "\n==== the totals line ====\n";
my %by;
$by{ $_->{verdict} }++ for @recs;
my $decided = grep { $_->{verdict} =~ /^(PASS|FAIL)$/ } @recs;
printf "  %s\n", join "   ", map { "$_ $by{$_}" }
    sort { $RANK{$a} <=> $RANK{$b} } keys %by;
printf "  %d run(s) total, %d reached a verdict, %d did not\n",
    scalar @recs, $decided, @recs - $decided;

# ---- 6. the filtered report, and the totals that must not be filtered with it
print "\n==== showing only what is wrong ====\n";
my @bad = grep { $_->{verdict} ne "PASS" } @ordered;
printf "rows shown: %d of %d\n", scalar @bad, scalar @recs;
my %badby; $badby{ $_->{verdict} }++ for @bad;
printf "totals computed over the SHOWN rows : %s  (total %d)\n",
    join(" ", map { "$_=$badby{$_}" } sort { $RANK{$a} <=> $RANK{$b} } keys %badby),
    scalar @bad;
printf "totals computed over ALL rows       : %s  (total %d)\n",
    join(" ", map { "$_=$by{$_}" } sort { $RANK{$a} <=> $RANK{$b} } keys %by),
    scalar @recs;
printf "\nthe filtered totals omit %d PASS run(s), so the report reads as\n",
    $by{PASS} // 0;
printf "%d of %d failing when the truth is %d of %d\n",
    scalar(@bad), scalar(@bad), scalar(@bad), scalar @recs;
print "a filtered report must still total the whole regression\n";

# ---- 7. one line per test instead of one line per run
print "\n==== the same night, one line per test ====\n";
my %byt;
push @{ $byt{ $_->{test} } }, $_ for @recs;
my $tw = 0;
for (keys %byt) { $tw = length if length > $tw }
printf "  %-*s %-28s %-10s %s\n", $tw, "TEST", "SEEDS", "VERDICT", "WHICH SEEDS FAILED";
printf "  %s\n", "-" x ($tw + 62);
my @trows = sort {
    $RANK{ worst($byt{$a}) } <=> $RANK{ worst($byt{$b}) } or $a cmp $b
} keys %byt;
sub worst {
    my ($rs) = @_;
    my ($w) = sort { $RANK{$a} <=> $RANK{$b} } map { $_->{verdict} } @$rs;
    return $w;
}
for my $t (@trows) {
    my @rs = @{ $byt{$t} };
    my %v; $v{ $_->{verdict} }++ for @rs;
    my @bad = map  { $_->{seed} }
              grep { $_->{verdict} eq "FAIL" } @rs;
    printf "  %-*s %-28s %-10s %s\n", $tw, $t,
        join(" ", map { "$_=$v{$_}" } sort { $RANK{$a} <=> $RANK{$b} } keys %v),
        worst(\@rs),
        @bad ? join(",", sort { $a <=> $b } @bad) : "-";
}
printf "\n%d row(s) instead of %d, and the failing seed is still on the row\n",
    scalar @trows, scalar @recs;
printf "the per-test verdict is the WORST of its seeds, not the most common:\n";
for my $t (@trows) {
    my @rs = @{ $byt{$t} };
    my %v; $v{ $_->{verdict} }++ for @rs;
    next unless keys %v > 1;
    my ($common) = sort { $v{$b} <=> $v{$a} || $RANK{$a} <=> $RANK{$b} } keys %v;
    printf "  %-18s worst %-8s most common %-8s %s\n", $t, worst(\@rs), $common,
        worst(\@rs) ne $common ? "<- they DISAGREE" : "";
}
Azvya Education Pvt. Ltd.VLSI Mentor
Captured output
==== sorting the seeds ====
sort keys          : 1 1234 42 7
sort { $a <=> $b }  : 1 7 42 1234
the two orders DISAGREE -- 1234 sorts before 42

==== the column width ====
longest test name: 16 characters (arb_random_short)

with a hardcoded %-12s:
  arb_budget   TIMEOUT     777
  arb_random   FAIL         55
  arb_random_short FAIL         17

with a width computed from the data (%-16s):
  arb_budget       TIMEOUT     777
  arb_random       FAIL         55
  arb_random_short FAIL         17
a name longer than the field does not truncate -- it pushes every
later column right, on that row only, which is what breaks a table

==== the row order ====
alphabetical : FAIL PASS TIMEOUT UNKNOWN
by severity  : FAIL TIMEOUT UNKNOWN PASS

==== the 9am summary ====
  TEST             SEED   VERDICT  ERRORS  SIMTIME
  ------------------------------------------------
  arb_light        7      FAIL          1  325 ns
  arb_random       1      FAIL         55  4025 ns
  arb_random       7      FAIL         62  4025 ns
  arb_random       42     FAIL         62  4025 ns
  arb_random       1234   FAIL         52  4025 ns
  arb_random_short 1      FAIL         17  1025 ns
  arb_random_short 7      FAIL         12  1025 ns
  arb_random_short 42     FAIL         14  1025 ns
  arb_random_short 1234   FAIL         12  1025 ns
  arb_soak         1      FAIL        162  4025 ns
  arb_soak         7      FAIL        161  4025 ns
  arb_soak         42     FAIL        159  4025 ns
  arb_soak         1234   FAIL        164  4025 ns
  arb_stress       1      FAIL        251  4025 ns
  arb_stress       7      FAIL        260  4025 ns
  arb_stress       42     FAIL        264  4025 ns
  arb_stress       1234   FAIL        263  4025 ns
  arb_trunc        1      FAIL         55  4025 ns
  arb_budget       1      TIMEOUT     777  12005 ns
  arb_budget       7      TIMEOUT     782  12005 ns
  arb_budget       42     TIMEOUT     788  12005 ns
  arb_budget       1234   TIMEOUT     781  12005 ns
  arb_trunc        7      UNKNOWN       8  -
  arb_trunc        42     UNKNOWN       8  -
  arb_trunc        1234   UNKNOWN       8  -
  arb_light        1      PASS          0  325 ns
  arb_light        42     PASS          0  325 ns
  arb_light        1234   PASS          0  325 ns
  arb_single       1      PASS          0  20025 ns
  arb_single       7      PASS          0  20025 ns
  arb_single       42     PASS          0  20025 ns
  arb_single       1234   PASS          0  20025 ns
  arb_smoke        1      PASS          0  4025 ns
  arb_smoke        7      PASS          0  4025 ns
  arb_smoke        42     PASS          0  4025 ns
  arb_smoke        1234   PASS          0  4025 ns

==== the totals line ====
  FAIL 18   TIMEOUT 4   UNKNOWN 3   PASS 11
  36 run(s) total, 29 reached a verdict, 7 did not

==== showing only what is wrong ====
rows shown: 25 of 36
totals computed over the SHOWN rows : FAIL=18 TIMEOUT=4 UNKNOWN=3  (total 25)
totals computed over ALL rows       : FAIL=18 TIMEOUT=4 UNKNOWN=3 PASS=11  (total 36)

the filtered totals omit 11 PASS run(s), so the report reads as
25 of 25 failing when the truth is 25 of 36
a filtered report must still total the whole regression

==== the same night, one line per test ====
  TEST             SEEDS                        VERDICT    WHICH SEEDS FAILED
  ------------------------------------------------------------------------------
  arb_light        FAIL=1 PASS=3                FAIL       7
  arb_random       FAIL=4                       FAIL       1,7,42,1234
  arb_random_short FAIL=4                       FAIL       1,7,42,1234
  arb_soak         FAIL=4                       FAIL       1,7,42,1234
  arb_stress       FAIL=4                       FAIL       1,7,42,1234
  arb_trunc        FAIL=1 UNKNOWN=3             FAIL       1
  arb_budget       TIMEOUT=4                    TIMEOUT    -
  arb_single       PASS=4                       PASS       -
  arb_smoke        PASS=4                       PASS       -

9 row(s) instead of 36, and the failing seed is still on the row
the per-test verdict is the WORST of its seeds, not the most common:
  arb_light          worst FAIL     most common PASS     <- they DISAGREE
  arb_trunc          worst FAIL     most common UNKNOWN  <- they DISAGREE
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sort keys          : 1 1234 42 7
sort { $a <=> $b }  : 1 7 42 1234
the two orders DISAGREE -- 1234 sorts before 42

sort compares as strings by default, so "1234" comes before "42" because 1 is less than 4. These are the four seeds the regression actually ran — 1, 7, 42 and 1234 — so this is not a contrived example waiting for someone else's data.

sort { $a <=> $b } fixes it (5.9, and 3.7 on <=> itself). The habit worth forming is to decide, every time you write sort, whether the keys are text or numbers — because the string sort is right for test names and wrong for seeds, and both appear in the same report.

2. The Column Width Comes From The Data

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
longest test name: 16 characters (arb_random_short)

with a hardcoded %-12s:
  arb_budget   TIMEOUT     777
  arb_random   FAIL         55
  arb_random_short FAIL         17

Look at the third row. A name longer than the field does not get truncated — it pushes every later column to the right, on that row only. One long name and the table stops being a table.

printf takes the width as an argument when you give it *:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
printf "  %-*s %-8s %6d\n", $longest, $r->{test}, $r->{verdict}, $r->{errors};

and $longest costs one pass over the records:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$longest = length $_->{test} for grep { length $_->{test} > $longest } @recs;

This matters more over time than it looks. The report is written when the longest name is twelve characters, works for a year, and breaks the week somebody adds arb_random_short. A measured width never has that week.

3. Failures First

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
alphabetical : FAIL PASS TIMEOUT UNKNOWN
by severity  : FAIL TIMEOUT UNKNOWN PASS

Sorting verdicts alphabetically puts PASS second, so eleven passing rows sit between the failures and the timeouts. The fix is a rank table:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
my %RANK = (FAIL => 0, TIMEOUT => 1, UNKNOWN => 2, PASS => 3);

and a comparator with three keys, each one breaking the previous tie:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sort { $RANK{$a->{verdict}} <=> $RANK{$b->{verdict}}
         or $a->{test} cmp $b->{test}
         or $a->{seed} <=> $b->{seed} } @recs

Note the three different comparisons in one sort — numeric on the rank, string on the name, numeric on the seed. That is section 1's point again, and it is also what makes the order total: no two rows compare equal, so the report comes out identically on every run regardless of hash order (6.6, 17.8).

4. The Report

The table in the captured output is the whole point of the chapter, and three things about it are deliberate.

Failures are at the top and the eye stops at the first PASS. Everything below that line is confirmation, not information.

The error count is right-aligned with %6d, so 777 and 12 line up on their last digit and a glance ranks them.

A run with no simulated time prints -, not 0. arb_trunc seeds 7, 42 and 1234 never reached the line that reports the time, and zero would be a claim about a run that made none.

5. The Totals Line

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  FAIL 18   TIMEOUT 4   UNKNOWN 3   PASS 11
  36 run(s) total, 29 reached a verdict, 7 did not

Two lines, and the second is the one most reports leave out. Seven runs never reached a verdict, and a reader who knows that reads the first line differently.

Now the trap, which appears the moment somebody asks for a shorter report:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
rows shown: 25 of 36
totals computed over the SHOWN rows : FAIL=18 TIMEOUT=4 UNKNOWN=3  (total 25)
totals computed over ALL rows       : the same plus PASS=11        (total 36)

the filtered totals omit 11 PASS run(s), so the report reads as
25 of 25 failing when the truth is 25 of 36

Filtering the rows and then totalling the rows gives a report that says 25 of 25 failed. Every number in it is correct. The denominator is simply gone, and with it the only thing that made the numbers mean anything.

So the rule for every filtered report in this module, and the reason the structure of 24.2 keeps all thirty-six runs:

Filter the rows. Never filter the totals. Say what was hidden, in the report:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
showing 25 non-passing of 36 runs

Chapter 24.3 found the same shape in a coverage percentage whose denominator was discarded, and Chapter 24.5 in a closure number that averaged rates instead of summing counts. A number without its denominator is the most reliable way to publish something true and misleading at once.

6. One Line Per Test

Thirty-six rows is a lot for a glance. The structure of 24.2 already groups the runs by test, so a one-line-per-test view costs nothing extra:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  TEST             SEEDS                        VERDICT    WHICH SEEDS FAILED
  ------------------------------------------------------------------------------
  arb_light        FAIL=1 PASS=3                FAIL       7
  arb_random       FAIL=4                       FAIL       1,7,42,1234
  arb_trunc        FAIL=1 UNKNOWN=3             FAIL       1
  arb_budget       TIMEOUT=4                    TIMEOUT    -
  arb_smoke        PASS=4                       PASS       -

9 row(s) instead of 36, and the failing seed is still on the row

Nine rows, and nothing a reader needs has been dropped. The seed breakdown is in a column rather than in four rows, and the failing seed is still there — which is 24.2's rule about storing what a rerun needs, surviving into the printed report.

The one real decision is what verdict a test gets when its seeds disagree, and it has a measured answer:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
the per-test verdict is the WORST of its seeds, not the most common:
  arb_light          worst FAIL     most common PASS     <- they DISAGREE
  arb_trunc          worst FAIL     most common UNKNOWN  <- they DISAGREE

On two of the nine tests, the worst verdict and the most common verdict are different. For arb_light the worst is FAIL and the most common is PASS — and PASS is the wrong answer, because a bug that reproduces one time in four is still a bug. Taking the majority would hide it under a green row.

So the per-test verdict is FAIL if any seed failed, and the seed count beside it is what stops that from being alarmist. The same rank table that ordered the rows does the reduction:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
my ($worst) = sort { $RANK{$a} <=> $RANK{$b} } map { $_->{verdict} } @runs;

A majority rule, an average, or a "mostly passing" column would each be defensible-sounding and each would hide the one bug this regression found intermittently.

7. What To Remember

  • sort is a string sort. Measured: the real seeds 1, 7, 42, 1234 come out 1 1234 42 7.
  • Decide every time whether the keys are text or numbers — a report usually has both (5.9, 3.7).
  • Measured: the longest test name is 16 characters, and a hardcoded %-12s pushes every later column right on that row.
  • A too-narrow field does not truncate, it shifts — which is what destroys a table.
  • Use %-*s with a width measured from the data; one pass, and it never has the week somebody adds a longer name.
  • Measured: alphabetical verdict order puts PASS second. Use a rank table and put failures first.
  • Make the sort total — rank, then name, then seed — so the report is identical on every run whatever the hash order (6.6, 17.8).
  • Right-align counts with %6d so a glance ranks them.
  • Print -, not 0, for a measurement a run never made.
  • Print a second totals line: how many runs reached a verdict and how many did not. Measured here: 29 and 7.
  • Measured: filtering rows and then totalling them turned "25 of 36 failing" into "25 of 25 failing".
  • Filter the rows; never filter the totals — and say in the report what was hidden.
  • A number without its denominator is true and misleading at once (24.3, 24.5).
  • A one-line-per-test view is 9 rows instead of 36 and drops nothing a reader needs.
  • Keep the failing seed in a column, so the printed report still says what to rerun (24.2).
  • Measured: the worst verdict and the most common verdict disagree on 2 of 9 tests.
  • A test's verdict is the WORST of its seeds, not the majority — arb_light is FAIL with three passes, and a bug that reproduces one time in four is still a bug.
  • The seed count beside the verdict is what keeps "worst wins" from being alarmist.

Chapter 24.7 makes this shorter. A mail body or a chat message gets a few lines, which forces a decision about what the first line says — and the shortest useful digest turns out to be the one that leaves the passing tests out entirely while still counting them.

Where this fits

Part of the Perl curriculum.