Perl Part II · Module 24 · Coverage and Regression Reporting
Trend Data: Comparing Today With Yesterday
The only question anybody asks of a nightly regression is what changed. Three lists answer it — newly failing, newly passing, still failing — and on this corpus the third list is a trap: all five of its entries are failing for a reason that was not there last night. This chapter builds the diff over the CSV files of the last two nights, and measures two ways the obvious loop reports a quiet night that did not happen.
Advanced12 min readPerlPart IIRegression Reporting
Part II · Module 24 · Chapter 24.11 · Trend Data: Comparing Today With Yesterday
1. Three Lists
The diff reads two files, not two data structures — last night's process is gone, and all that remains of it is what it wrote.
use strict;
use warnings;
my %RANK = (FAIL => 0, TIMEOUT => 1, UNKNOWN => 2, PASS => 3);
my @COLS = qw(test verdict seeds fails first_id);
sub csv_field {
my ($v) = @_;
$v = '' unless defined $v;
return $v =~ /[",\n]/ ? '"' . ($v =~ s/"/""/gr) . '"' : $v;
}
sub csv_row { return join(",", map { csv_field($_) } @_) . "\n" }
sub csv_parse {
my ($line) = @_;
$line =~ s/\r?\n\z//;
my @f;
pos($line) = 0;
while (1) {
if ($line =~ /\G"((?:[^"]|"")*)"/gc) { my $v = $1; $v =~ s/""/"/g; push @f, $v }
elsif ($line =~ /\G([^,]*)/gc) { push @f, $1 }
last unless $line =~ /\G,/gc;
}
return @f;
}
# ---- one night's per-test rows, written as the CSV of 24.8
sub night_rows {
my ($dir) = @_;
my %by;
for my $p (sort glob("$dir/*.log")) {
open my $fh, "<", $p or next;
my %r = (verdict => "UNKNOWN");
while (my $l = <$fh>) {
$r{test} = $1 if $l =~ /^TEST:\s+(\S+)/;
$r{seed} = $1 if $l =~ /^SEED:\s+(\d+)/;
$r{budget} = 1 if $l =~ /^UVM_FATAL\s.*\[BUDGET\]/;
if ($l =~ /^UVM_ERROR\s+\S+\(\d+\)/ && !defined $r{first_id}) {
$r{first_id} = $1 if $l =~ /\[(\w+)\]/;
}
$r{verdict} = "PASS" if $l =~ /^TEST PASSED/;
$r{verdict} = "FAIL" if $l =~ /^TEST FAILED/;
}
close $fh;
$r{verdict} = "TIMEOUT" if $r{budget} && $r{verdict} eq "UNKNOWN";
push @{ $by{ $r{test} } }, \%r if defined $r{test};
}
my @rows;
for my $t (sort keys %by) {
my @r = sort { $a->{seed} <=> $b->{seed} } @{ $by{$t} };
my ($worst) = sort { $RANK{$a} <=> $RANK{$b} } map { $_->{verdict} } @r;
my $fails = grep { $_->{verdict} eq "FAIL" } @r;
my ($id) = grep { defined } map { $_->{first_id} }
grep { $_->{verdict} eq "FAIL" } @r;
push @rows, [$t, $worst, scalar @r, $fails, $id // ""];
}
return @rows;
}
for my $pair (["prev", "prev.csv"], ["logs", "today.csv"]) {
my ($dir, $file) = @$pair;
open my $o, ">", $file or die $!;
print {$o} csv_row(@COLS);
print {$o} csv_row(@$_) for night_rows($dir);
close $o;
}
printf "wrote prev.csv and today.csv\n";
sub load {
my ($file) = @_;
open my $fh, "<", $file or die "$file: $!";
my @hdr = csv_parse(scalar <$fh>);
my %by;
while (my $l = <$fh>) {
my @f = csv_parse($l);
my %r;
@r{@hdr} = @f;
$by{ $r{test} } = \%r;
}
close $fh;
return \%by;
}
my $was = load("prev.csv");
my $now = load("today.csv");
printf "prev.csv %d test(s), today.csv %d test(s)\n",
scalar keys %$was, scalar keys %$now;
# ---- 1. the three lists
print "\n==== the three lists ====\n";
my (@newly_fail, @newly_pass, @unchanged);
for my $t (sort keys %$now) {
my $n = $now->{$t}{verdict};
my $w = $was->{$t} ? $was->{$t}{verdict} : undef;
if (!defined $w) { next }
elsif ($n ne "PASS" && $w eq "PASS") { push @newly_fail, $t }
elsif ($n eq "PASS" && $w ne "PASS") { push @newly_pass, $t }
elsif ($n eq $w) { push @unchanged, $t }
}
printf "newly failing : %d %s\n", scalar @newly_fail, join " ", @newly_fail;
printf "newly passing : %d %s\n", scalar @newly_pass, join " ", @newly_pass;
printf "unchanged : %d %s\n", scalar @unchanged, join " ", @unchanged;
# ---- 2. 'unchanged' is not the same as 'nothing happened'
print "\n==== did the unchanged tests fail for the same reason? ====\n";
printf "%-18s %-9s %-11s %-11s %s\n", "TEST", "VERDICT", "WAS", "NOW", "";
my $reason_moved = 0;
for my $t (@unchanged) {
my ($a, $b) = ($was->{$t}{first_id}, $now->{$t}{first_id});
$a = "-" if !defined $a || $a eq "";
$b = "-" if !defined $b || $b eq "";
my $moved = $a ne $b;
$reason_moved++ if $moved;
printf "%-18s %-9s %-11s %-11s %s\n", $t, $now->{$t}{verdict}, $a, $b,
$moved ? "SAME VERDICT, DIFFERENT REASON" : "same reason";
}
printf "\n%d of %d unchanged tests changed their failure reason\n",
$reason_moved, scalar @unchanged;
print "a verdict-only diff calls all of them 'still failing', which reads as\n";
print "'nothing happened' -- and what happened is that one bug was fixed and\n";
print "another was introduced across the whole regression\n";
# ---- 3. the 'ne PASS' rule accuses a test that never failed
print "\n==== what 'ne PASS' puts in the failing lists ====\n";
for my $t (sort keys %$now) {
next unless ($now->{$t}{verdict} // "") !~ /^(PASS|FAIL)$/;
printf " %-18s was %-8s now %-8s fails %s of %s seeds\n",
$t, $was->{$t}{verdict} // "-", $now->{$t}{verdict},
$now->{$t}{fails}, $now->{$t}{seeds};
}
print "arb_budget is TIMEOUT on both nights with 0 failing seeds on each, so\n";
print "it lands in 'unchanged' -- correct -- but a rule written as\n";
print "'ne PASS' would have called it failing on both nights\n";
# ---- 4. a test that disappears is invisible to a diff over today's keys
print "\n==== a test that vanished ====\n";
my @gone = grep { !exists $now->{$_} } sort keys %$was;
my @added = grep { !exists $was->{$_} } sort keys %$now;
printf "in prev.csv but not in today.csv : %d %s\n", scalar @gone, join " ", @gone;
printf "in today.csv but not in prev.csv : %d %s\n", scalar @added, join " ", @added;
# now take one away and look again
my %short = %$now;
delete $short{arb_stress};
my @lists_over_now = grep {
my $n = $short{$_}{verdict};
my $w = $was->{$_}{verdict};
$n ne "PASS" && $w eq "PASS"
} sort keys %short;
printf "\nwith arb_stress removed from today's file:\n";
printf " iterating today's keys -> newly failing: %d %s\n",
scalar @lists_over_now, join " ", @lists_over_now;
my @vanished = grep { !exists $short{$_} } sort keys %$was;
printf " iterating the UNION -> vanished: %d %s\n",
scalar @vanished, join " ", @vanished;
print "arb_stress was the only newly-failing test. Dropping its row makes the\n";
print "diff report nothing wrong at all, because the loop never visits a test\n";
print "today's file does not mention. Iterate the UNION of both nights.\n";
# ---- 5. the report
print "\n==== the trend report ====\n";
printf "REGRESSION TREND %s -> %s\n", "2026-10-12", "2026-10-13";
printf " newly failing : %d\n", scalar @newly_fail;
printf " %-18s was %-8s now %-8s first error %s\n",
$_, $was->{$_}{verdict}, $now->{$_}{verdict},
$now->{$_}{first_id} || "-" for @newly_fail;
printf " newly passing : %d\n", scalar @newly_pass;
printf " %-18s was %-8s now %-8s\n",
$_, $was->{$_}{verdict}, $now->{$_}{verdict} for @newly_pass;
printf " still failing : %d\n",
scalar grep { $now->{$_}{verdict} eq "FAIL" } @unchanged;
for my $t (@unchanged) {
next unless $now->{$t}{verdict} eq "FAIL";
my ($a, $b) = ($was->{$t}{first_id} || "-", $now->{$t}{first_id} || "-");
printf " %-18s %s%s\n", $t, $b,
$a ne $b ? " (was $a -- the reason CHANGED)" : "";
}
printf " unchanged and not failing : %d %s\n",
scalar(grep { $now->{$_}{verdict} ne "FAIL" } @unchanged),
join " ", grep { $now->{$_}{verdict} ne "FAIL" } @unchanged;
printf " tests in one night only : %d\n", scalar(@gone) + scalar(@added);wrote prev.csv and today.csv
prev.csv 9 test(s), today.csv 9 test(s)
==== the three lists ====
newly failing : 1 arb_stress
newly passing : 2 arb_single arb_smoke
unchanged : 6 arb_budget arb_light arb_random arb_random_short arb_soak arb_trunc
==== did the unchanged tests fail for the same reason? ====
TEST VERDICT WAS NOW
arb_budget TIMEOUT - - same reason
arb_light FAIL GNT_VALID GNT_VEC SAME VERDICT, DIFFERENT REASON
arb_random FAIL GNT_VALID GNT_VEC SAME VERDICT, DIFFERENT REASON
arb_random_short FAIL GNT_VALID GNT_VEC SAME VERDICT, DIFFERENT REASON
arb_soak FAIL GNT_VALID GNT_VEC SAME VERDICT, DIFFERENT REASON
arb_trunc FAIL GNT_VALID GNT_VEC SAME VERDICT, DIFFERENT REASON
5 of 6 unchanged tests changed their failure reason
a verdict-only diff calls all of them 'still failing', which reads as
'nothing happened' -- and what happened is that one bug was fixed and
another was introduced across the whole regression
==== what 'ne PASS' puts in the failing lists ====
arb_budget was TIMEOUT now TIMEOUT fails 0 of 4 seeds
arb_budget is TIMEOUT on both nights with 0 failing seeds on each, so
it lands in 'unchanged' -- correct -- but a rule written as
'ne PASS' would have called it failing on both nights
==== a test that vanished ====
in prev.csv but not in today.csv : 0
in today.csv but not in prev.csv : 0
with arb_stress removed from today's file:
iterating today's keys -> newly failing: 0
iterating the UNION -> vanished: 1 arb_stress
arb_stress was the only newly-failing test. Dropping its row makes the
diff report nothing wrong at all, because the loop never visits a test
today's file does not mention. Iterate the UNION of both nights.
==== the trend report ====
REGRESSION TREND 2026-10-12 -> 2026-10-13
newly failing : 1
arb_stress was PASS now FAIL first error GNT_VEC
newly passing : 2
arb_single was FAIL now PASS
arb_smoke was FAIL now PASS
still failing : 5
arb_light GNT_VEC (was GNT_VALID -- the reason CHANGED)
arb_random GNT_VEC (was GNT_VALID -- the reason CHANGED)
arb_random_short GNT_VEC (was GNT_VALID -- the reason CHANGED)
arb_soak GNT_VEC (was GNT_VALID -- the reason CHANGED)
arb_trunc GNT_VEC (was GNT_VALID -- the reason CHANGED)
unchanged and not failing : 1 arb_budget
tests in one night only : 0Figure 24.11.1 — The trend, from two files to three lists
The three lists the registry asks for come out like this:
newly failing : 1 arb_stress
newly passing : 2 arb_single arb_smoke
unchanged : 6 arb_budget arb_light arb_random arb_random_short arb_soak arb_truncOne test started failing, two started passing. Read that on its own and the night looks like modest progress.
2. "Unchanged" Is Not "Nothing Happened"
TEST VERDICT WAS NOW
arb_budget TIMEOUT - - same reason
arb_light FAIL GNT_VALID GNT_VEC SAME VERDICT, DIFFERENT REASON
arb_random FAIL GNT_VALID GNT_VEC SAME VERDICT, DIFFERENT REASON
arb_random_short FAIL GNT_VALID GNT_VEC SAME VERDICT, DIFFERENT REASON
arb_soak FAIL GNT_VALID GNT_VEC SAME VERDICT, DIFFERENT REASON
arb_trunc FAIL GNT_VALID GNT_VEC SAME VERDICT, DIFFERENT REASON
5 of 6 unchanged tests changed their failure reasonFive of the six tests with an unchanged verdict are failing for a different reason than last night.
What happened to this design is not subtle once the ids are visible. Last night the arbiter asserted a grant when nobody was requesting the bus — GNT_VALID. Tonight that is fixed and the round-robin pointer no longer advances — GNT_VEC. One bug out, one bug in, and a verdict-only diff describes it as five tests that are still failing, which is the sentence people read as "no change, carry on".
The two bugs even sort the tests differently, which is the other half of the story: arb_stress never idles the bus, so it could not see last night's bug and it does see tonight's; arb_smoke and arb_single drive one requester at a time, so they saw last night's and cannot see tonight's. The three lists are a shadow of which stimulus profile notices which bug.
So the comparison key is the pair, not the verdict:
($was->{$t}{verdict}, $was->{$t}{first_id}) against ($now->{$t}{verdict}, $now->{$t}{first_id})That is why Chapter 24.9 put the first-error id in the published table. A column that looked like a convenience is the only thing distinguishing "still broken" from "differently broken".
3. "Not A Pass" Accuses The Innocent, Again
The registry's own sketch for this chapter is written with ne 'PASS':
my @new_fail = grep { $now{$_} ne 'PASS' && ($was{$_} // '') eq 'PASS' } sort keys %now;and the corpus has exactly the test that catches it:
arb_budget was TIMEOUT now TIMEOUT fails 0 of 4 seedsarb_budget is TIMEOUT on both nights with zero failing seeds on each. It is genuinely unchanged — the cycle budget stopped it both times — and under ne 'PASS' it reads as failing on both nights, so it would appear in a "still failing" list it has no business in.
Chapter 24.2 measured this rule misreporting two of nine tests; here it is again at the trend layer. The fix is the same three characters, and the reason to keep saying it is that ne 'PASS' is genuinely the shorter and more natural thing to write.
4. A Test That Vanished Is Invisible
Now the defect that is hardest to see, because it hides in the loop rather than in the comparison:
with arb_stress removed from today's file:
iterating today's keys -> newly failing: 0
iterating the UNION -> vanished: 1 arb_stressDelete one row from tonight's file and the diff reports nothing wrong at all. arb_stress was the only newly-failing test, and sort keys %$now never visits a test tonight's file does not mention. The report comes out clean, with correct arithmetic, describing a regression in which a test silently stopped existing.
That is the third time this exact shape has appeared in two modules:
| where | what disappeared | what the report said |
|---|---|---|
| 23.9 | one corner's cell in a slack store | a VIOLATED endpoint read 0.5070 MET |
| 24.4 | a bin absent from the expected list | 40 of 40 bins hit — 100% |
| 24.11 | a test absent from tonight's file | newly failing: 0 |
A reduction over the wrong set cannot see what is missing from it. So the loop walks the union and the report carries a fourth list:
for my $t (sort keys %{ { %$was, %$now } }) { ... }and then exists (6.4) distinguishes the three cases a test can be in: in both nights, only in last night's, or only in tonight's. A test that is new is usually fine; a test that vanished is usually not.
5. The Report
REGRESSION TREND 2026-10-12 -> 2026-10-13
newly failing : 1
arb_stress was PASS now FAIL first error GNT_VEC
newly passing : 2
arb_single was FAIL now PASS
arb_smoke was FAIL now PASS
still failing : 5
arb_light GNT_VEC (was GNT_VALID -- the reason CHANGED)
unchanged and not failing : 1 arb_budget
tests in one night only : 0Four lists and two counts. Every line says what changed, and the "still failing" entries say that their reason changed — so nobody reads this report as a quiet night.
And the last line is the one that is worth having precisely because it is usually zero. tests in one night only: 0 is the report asserting that it compared the same set of tests on both nights. Chapter 24.9 put its self-checks in the published output for the same reason: a reader three weeks later cannot re-run the check, so the artefact has to carry it.
6. What To Remember
- The diff is over files, not data structures — last night's process is gone, and all that remains is what it wrote (which is why 24.8's stable columns and 24.9's deterministic rows came first).
- Three lists: newly failing, newly passing, still failing. Measured here: 1, 2 and 5.
- Measured: 5 of the 6 unchanged tests changed their failure reason,
GNT_VALIDtoGNT_VEC. - An unchanged verdict is not an unchanged situation — one bug was fixed and another introduced, and a verdict-only diff calls that "still failing".
- Compare the pair
(verdict, first_id), which is why the id is in the published table (24.9). - The three lists are a shadow of which stimulus profile notices which bug —
arb_stressnever idles, so it could not see last night's bug and does see tonight's. - Measured:
arb_budgetis TIMEOUT both nights with 0 failing seeds, andne 'PASS'would list it as failing twice (24.2). - Measured: deleting the only newly-failing row made the diff report
newly failing: 0. sort keys %$nownever visits a test tonight's file does not mention — walk the union of both nights.- This is the third appearance of one shape: a missing cell (23.9), a bin outside the expected list (24.4), a test missing from a file (24.11). A reduction over the wrong set cannot see what is missing from it.
- Use
existsto separate "in both", "only last night" and "only tonight" (6.4) — a new test is usually fine, a vanished one usually is not. - Publish a fourth list and a count of tests present in one night only, even when it is zero: that line is the report asserting it compared the same set twice.
That closes Module 24. The module began by deciding what one test's result holds and ended by comparing two nights of them, and the same finding kept returning in different costumes: a number is only as good as the set it was computed over. A pass rate without its denominator, a coverage percentage without its bin list, a closure figure that averaged rates, a filtered total, a CSV column order taken from a hash, a diff over one night's keys — every one of them was arithmetically correct and described something that had not happened.
Module 25 moves upstream of all of it. Instead of reading what a regression produced, Perl starts the runs: the test list, the seeds and plusargs, launching and capturing, retrying what failed, and making the whole thing reproducible — which is the only way the reports in this module can be trusted twice.
Where this fits
Part of the Perl curriculum.
